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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * File helpers for uploads: reading whatever the caller passed as a file,
3
+ * resolving its MIME type, and reporting sizes.
4
+ */
5
+
6
+ import fs from "fs";
7
+ import { VaultError } from "./validationError.js";
8
+
9
+ export const MAX_FILE_SIZE = 10 * 1024 * 1024 * 1024;
10
+
11
+ /** Largest buffer this runtime can allocate; reading past it aborts the process. */
12
+ export const MAX_BUFFERABLE_SIZE =
13
+ Buffer?.constants?.MAX_LENGTH ?? Buffer?.kMaxLength ?? 2 ** 31 - 1;
14
+
15
+ /** Extension → MIME type */
16
+ const CONTENT_TYPES = {
17
+ pdf: "application/pdf",
18
+ doc: "application/msword",
19
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
20
+ txt: "text/plain",
21
+ jpg: "image/jpeg",
22
+ jpeg: "image/jpeg",
23
+ png: "image/png",
24
+ gif: "image/gif",
25
+ mp4: "video/mp4",
26
+ mp3: "audio/mpeg",
27
+ zip: "application/zip",
28
+ json: "application/json",
29
+ csv: "text/csv",
30
+ };
31
+
32
+ /**
33
+ * MIME type for a file name, falling back to "application/octet-stream".
34
+ *
35
+ * @param {string} fileName
36
+ * @returns {string}
37
+ */
38
+ export function contentTypeFor(fileName) {
39
+ const extension = String(fileName).split(".").pop()?.toLowerCase();
40
+ return CONTENT_TYPES[extension] || "application/octet-stream";
41
+ }
42
+
43
+ /** Last path segment of a path or name, for both "/" and "\" separators. */
44
+ export const baseName = (filePath) =>
45
+ String(filePath).split(/[\\/]/).pop() || "";
46
+
47
+ /** Human-readable byte count, e.g. "1.5 GB". Mirrors the backend formatter. */
48
+ export function formatFileSize(bytes) {
49
+ if (!bytes) return "0 Bytes";
50
+ const k = 1024;
51
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
52
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
53
+ return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`;
54
+ }
55
+
56
+ /** Anything byte-shaped → a Buffer, or null when the value isn't bytes. */
57
+ export function toBuffer(value) {
58
+ if (Buffer.isBuffer(value)) return value;
59
+ if (value instanceof ArrayBuffer) return Buffer.from(value);
60
+ if (ArrayBuffer.isView(value)) {
61
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Reject a file that is too large, before its bytes are read into memory.
68
+ *
69
+ * @param {number} size - Size in bytes
70
+ * @param {string} name - File name, used in the message
71
+ * @param {string} operation - Calling method name
72
+ * @throws {VaultError} With code FILE_TOO_LARGE
73
+ */
74
+ export function assertNotTooLarge(size, name, operation) {
75
+ if (!Number.isFinite(size)) return;
76
+
77
+ const limit = Math.min(MAX_FILE_SIZE, MAX_BUFFERABLE_SIZE);
78
+ if (size > limit) {
79
+ const cap =
80
+ limit === MAX_FILE_SIZE
81
+ ? `the maximum upload size of ${formatFileSize(MAX_FILE_SIZE)}`
82
+ : `what this runtime can hold in memory (${formatFileSize(limit)})`;
83
+ throw new VaultError(
84
+ `[Vault SDK] '${operation}': "${name}" is ${formatFileSize(size)}, which exceeds ${cap}.`,
85
+ { code: "FILE_TOO_LARGE", operation }
86
+ );
87
+ }
88
+ }
89
+
90
+ /** The working directory, when the runtime has one. */
91
+ const defaultUploadRoot = () =>
92
+ typeof process !== "undefined" && typeof process.cwd === "function"
93
+ ? process.cwd()
94
+ : null;
95
+
96
+ /**
97
+ * Resolve a caller-supplied path and confirm it stays inside `root`.
98
+ *
99
+ * Both sides go through realpath first, so a symlink cannot step out of the
100
+ * root. Messages name the file only, never the resolved path.
101
+ */
102
+ async function resolveWithinRoot(filePath, root, fail) {
103
+ if (
104
+ typeof fs?.promises?.realpath !== "function" ||
105
+ typeof fs?.promises?.stat !== "function"
106
+ ) {
107
+ fail(
108
+ "Reading files from disk is not available here. Pass a File/Blob or { buffer, name } instead of a path.",
109
+ "FILE_READ_FAILED"
110
+ );
111
+ }
112
+
113
+ const configuredRoot = root || defaultUploadRoot();
114
+ if (!configuredRoot) {
115
+ fail(
116
+ "No upload directory is configured, so paths on disk cannot be read. Set VAULT_UPLOAD_ROOT, or pass { buffer, name } instead.",
117
+ "PATH_NOT_ALLOWED"
118
+ );
119
+ }
120
+
121
+ let realRoot;
122
+ try {
123
+ realRoot = await fs.promises.realpath(configuredRoot);
124
+ } catch {
125
+ fail(
126
+ "The configured upload directory does not exist. Check VAULT_UPLOAD_ROOT.",
127
+ "PATH_NOT_ALLOWED"
128
+ );
129
+ }
130
+
131
+ let realPath;
132
+ try {
133
+ realPath = await fs.promises.realpath(filePath);
134
+ } catch (error) {
135
+ fail(
136
+ `Could not read "${baseName(filePath)}" — ${error.code || error.message}.`,
137
+ "FILE_READ_FAILED"
138
+ );
139
+ }
140
+
141
+ const separator = realRoot.includes("\\") ? "\\" : "/";
142
+ const caseInsensitive = /^[A-Za-z]:/.test(realRoot);
143
+ const normalize = (value) => (caseInsensitive ? value.toLowerCase() : value);
144
+ const base = normalize(
145
+ realRoot.endsWith(separator)
146
+ ? realRoot.slice(0, -separator.length)
147
+ : realRoot
148
+ );
149
+ const target = normalize(realPath);
150
+
151
+ if (target !== base && !target.startsWith(base + separator)) {
152
+ fail(
153
+ `"${baseName(filePath)}" is outside the allowed upload directory. ` +
154
+ `Pass a file inside it, set VAULT_UPLOAD_ROOT to the directory you upload from, or hand over { buffer, name } instead.`,
155
+ "PATH_NOT_ALLOWED"
156
+ );
157
+ }
158
+
159
+ return realPath;
160
+ }
161
+
162
+ /**
163
+ * Turn whatever the caller passed as `file` into { buffer, name, type }.
164
+ *
165
+ * Accepts a path on disk, a File/Blob, or an object carrying the bytes under
166
+ * any of the common property names, so callers only ever hand over a file.
167
+ *
168
+ * @param {string|Object|Blob} input - The file, in any supported form
169
+ * @param {string} operation - Calling method name, used in error messages
170
+ * @param {Object} [options] - { uploadRoot }: the directory paths must stay inside
171
+ * @returns {Promise<{buffer: Buffer, name: string, type: string|undefined}>}
172
+ * @throws {VaultError} If the file is unreadable, too large, or outside the upload root
173
+ */
174
+ export async function resolveFile(input, operation, options = {}) {
175
+ const fail = (message, code = "INVALID_PARAMETER") => {
176
+ throw new VaultError(`[Vault SDK] '${operation}': ${message}`, {
177
+ code,
178
+ operation,
179
+ });
180
+ };
181
+
182
+ if (input === undefined || input === null || input === "") {
183
+ fail(
184
+ "The 'file' parameter is required. Pass a path on disk, a File/Blob, or an object like { buffer, name }."
185
+ );
186
+ }
187
+
188
+ const readPath = async (filePath) => {
189
+ const realPath = await resolveWithinRoot(filePath, options.uploadRoot, fail);
190
+
191
+ let stats;
192
+ try {
193
+ stats = await fs.promises.stat(realPath);
194
+ } catch (error) {
195
+ fail(
196
+ `Could not read "${baseName(filePath)}" — ${error.code || error.message}.`,
197
+ "FILE_READ_FAILED"
198
+ );
199
+ }
200
+
201
+ if (!stats.isFile()) {
202
+ fail(`"${baseName(filePath)}" is not a file.`, "FILE_READ_FAILED");
203
+ }
204
+
205
+ assertNotTooLarge(stats.size, baseName(filePath), operation);
206
+
207
+ try {
208
+ return await fs.promises.readFile(realPath);
209
+ } catch (error) {
210
+ fail(
211
+ `Could not read "${baseName(filePath)}" — ${error.code || error.message}.`,
212
+ "FILE_READ_FAILED"
213
+ );
214
+ }
215
+ };
216
+
217
+ // uploadFile("./photo.jpg", vaultId)
218
+ if (typeof input === "string") {
219
+ const buffer = await readPath(input);
220
+ return { buffer, name: baseName(input), type: undefined };
221
+ }
222
+
223
+ // A bare Buffer carries no name, so there is nothing to store it under.
224
+ if (toBuffer(input) && !input.name) {
225
+ fail(
226
+ "A raw buffer has no file name. Pass { buffer, name } instead, e.g. { buffer, name: 'document.pdf' }."
227
+ );
228
+ }
229
+
230
+ if (typeof input !== "object") {
231
+ fail(
232
+ "The 'file' parameter must be a path, a File/Blob, or an object like { buffer, name }."
233
+ );
234
+ }
235
+
236
+ const name =
237
+ input.name || input.fileName || input.filename || baseName(input.path || "");
238
+ const type = input.type || input.mimeType || input.contentType;
239
+
240
+ // File / Blob (browser, or Node 20+ globals)
241
+ if (typeof input.arrayBuffer === "function") {
242
+ assertNotTooLarge(input.size, name || "file", operation);
243
+ const buffer = Buffer.from(await input.arrayBuffer());
244
+ assertNotTooLarge(buffer.length, name || "file", operation);
245
+ return { buffer, name, type };
246
+ }
247
+
248
+ const raw = input.buffer ?? input.data ?? input.content ?? input.bytes;
249
+ const buffer = raw
250
+ ? toBuffer(raw)
251
+ : input.path
252
+ ? await readPath(input.path)
253
+ : null;
254
+
255
+ if (!buffer) {
256
+ fail(
257
+ "Could not read the file content. Provide file.buffer as a Buffer/Uint8Array, or file.path pointing at a file on disk."
258
+ );
259
+ }
260
+
261
+ if (!name) {
262
+ fail(
263
+ "file.name is required. Provide the file name as a non-empty string (e.g. 'document.pdf')."
264
+ );
265
+ }
266
+
267
+ assertNotTooLarge(buffer.length, name, operation);
268
+
269
+ return { buffer, name, type };
270
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * File name sanitization — mirrors the vault backend and frontend sanitizer so
3
+ * a name the SDK sends is the same name the server would have derived itself.
4
+ */
5
+
6
+ export const MAX_FILENAME_LENGTH = 255;
7
+
8
+ /** Windows rejects these as file names whatever the extension. */
9
+ const RESERVED_NAMES = new Set([
10
+ "CON",
11
+ "PRN",
12
+ "AUX",
13
+ "NUL",
14
+ ...Array.from({ length: 9 }, (_, i) => `COM${i + 1}`),
15
+ ...Array.from({ length: 9 }, (_, i) => `LPT${i + 1}`),
16
+ ]);
17
+
18
+ /** Illegal in a Windows path, a URL path segment, or both. */
19
+ const ILLEGAL_CHARS = /[<>:"/\\|?*]/g;
20
+
21
+ const foldToAscii = (value) =>
22
+ value
23
+ .normalize("NFKD")
24
+ .replace(/\p{M}/gu, "")
25
+ .replace(/[^\x20-\x7E]/g, "");
26
+
27
+ /** Leading dots are stripped: hidden files and "../" are not names we store. */
28
+ const tidy = (value) =>
29
+ foldToAscii(value)
30
+ .replace(ILLEGAL_CHARS, "")
31
+ .replace(/\s+/g, " ")
32
+ .replace(/^[\s.]+|[\s.]+$/g, "");
33
+
34
+ /** Splits "report.final.pdf" into { base: "report.final", ext: "pdf" }. */
35
+ export const splitExtension = (name) => {
36
+ const dot = name.lastIndexOf(".");
37
+ if (dot <= 0) return { base: name, ext: "" };
38
+ return { base: name.slice(0, dot), ext: name.slice(dot + 1) };
39
+ };
40
+
41
+ /**
42
+ * @param {string} name - Raw name as the caller supplied it
43
+ * @param {string} [fallback="file"] - Used when nothing usable survives.
44
+ * @returns {string} ASCII-safe name, at most MAX_FILENAME_LENGTH characters
45
+ *
46
+ */
47
+ export function sanitizeFileName(name, fallback = "file") {
48
+ if (typeof name !== "string" || !name) return fallback;
49
+
50
+ const segment = name.split(/[\\/]/).pop() ?? "";
51
+ const { base, ext } = splitExtension(segment);
52
+
53
+ let safeBase = tidy(base) || fallback;
54
+ if (RESERVED_NAMES.has(safeBase.toUpperCase())) {
55
+ safeBase = `_${safeBase}`;
56
+ }
57
+
58
+ const safeExt = tidy(ext).slice(0, 32);
59
+ const suffix = safeExt ? `.${safeExt}` : "";
60
+
61
+ const room = Math.max(1, MAX_FILENAME_LENGTH - suffix.length);
62
+ const truncated = safeBase.slice(0, room).replace(/[\s.]+$/, "") || fallback;
63
+
64
+ if (!truncated) return "";
65
+
66
+ return `${truncated}${suffix}`;
67
+ }
@@ -14,7 +14,10 @@ export class ValidationError extends Error {
14
14
  this.param = param;
15
15
  this.expectedType = expectedType;
16
16
 
17
- Error.captureStackTrace(this, ValidationError);
17
+ // Chrome and Node only: Safari and older Firefox throw on it.
18
+ if (typeof Error.captureStackTrace === "function") {
19
+ Error.captureStackTrace(this, ValidationError);
20
+ }
18
21
  }
19
22
  }
20
23
 
@@ -23,21 +26,53 @@ export class ValidationError extends Error {
23
26
  * Wraps HTTP errors with status codes and structured error data.
24
27
  */
25
28
  export class VaultError extends Error {
26
- constructor(message, { status, code, operation, data } = {}) {
29
+ constructor(message, { status, code, operation, data, requestId } = {}) {
27
30
  super(message);
28
31
  this.name = "VaultError";
29
32
  this.status = status || null;
30
33
  this.code = code || "VAULT_ERROR";
31
34
  this.operation = operation || null;
32
35
  this.data = data || null;
36
+ this.requestId = requestId || null;
33
37
 
34
- Error.captureStackTrace(this, VaultError);
38
+ // Chrome and Node only: Safari and older Firefox throw on it.
39
+ if (typeof Error.captureStackTrace === "function") {
40
+ Error.captureStackTrace(this, VaultError);
41
+ }
35
42
  }
36
43
  }
37
44
 
38
45
  /**
39
46
  * Maps HTTP status codes to user-friendly error descriptions.
40
47
  */
48
+ /**
49
+ * Reduce a server error body to what an integrator may safely see.
50
+ *
51
+ * Upstream bodies can carry stack traces, database table names and internal
52
+ * hostnames, so only the message, code and request id cross the boundary.
53
+ *
54
+ * @param {*} body - Response body from the server
55
+ * @returns {{message?: string, code?: string, requestId?: string}|null}
56
+ */
57
+ export function safeErrorDetails(body) {
58
+ if (!body || typeof body !== "object") return null;
59
+
60
+ const text = (value) =>
61
+ typeof value === "string" && value.trim() ? value.trim() : undefined;
62
+
63
+ const details = {
64
+ message: text(body.message) ?? text(body.error),
65
+ code: text(body.code),
66
+ requestId: text(body.requestId) ?? text(body.request_id),
67
+ };
68
+
69
+ for (const key of Object.keys(details)) {
70
+ if (details[key] === undefined) delete details[key];
71
+ }
72
+
73
+ return Object.keys(details).length ? details : null;
74
+ }
75
+
41
76
  export const HTTP_ERROR_MAP = {
42
77
  400: { code: "BAD_REQUEST", description: "The request was invalid. Check your parameters." },
43
78
  401: { code: "UNAUTHORIZED", description: "Authentication failed. Verify your VAULT_ACCESS_KEY and VAULT_SECRET_KEY." },
@@ -51,12 +86,33 @@ export const HTTP_ERROR_MAP = {
51
86
  503: { code: "SERVICE_UNAVAILABLE", description: "The service is temporarily unavailable. Please try again later." },
52
87
  };
53
88
 
89
+ // `typeof ""` and `typeof "abc"` both read as "string", which turns a rejected
90
+ // empty field into "must be a valid string. Received: string." Name what was
91
+ // actually wrong with the value instead.
92
+ const describe = (value) => {
93
+ if (typeof value === "string") {
94
+ if (value === "") return "an empty string";
95
+ if (value.trim() === "") return "a blank string";
96
+ return "string";
97
+ }
98
+ if (Array.isArray(value)) return value.length === 0 ? "an empty array" : "array";
99
+ if (value === null) return "null";
100
+ if (typeof value === "number") {
101
+ if (Number.isNaN(value)) return "NaN";
102
+ if (!Number.isFinite(value)) return String(value);
103
+ if (value < 0) return `a negative number (${value})`;
104
+ if (!Number.isInteger(value)) return `a decimal (${value})`;
105
+ }
106
+ return typeof value;
107
+ };
108
+
54
109
  export const validator = {
55
110
  types: {
56
111
  string: (value) => typeof value === "string" && value.trim() !== "",
57
112
  object: (value) => typeof value === "object" && value !== null,
58
113
  array: (value) => Array.isArray(value) && value.length > 0,
59
- number: (value) => typeof value === "number" && !isNaN(value) && value >= 0,
114
+ number: (value) => Number.isFinite(value) && value >= 0,
115
+ integer: (value) => Number.isInteger(value) && value >= 0,
60
116
  boolean: (value) => typeof value === "boolean",
61
117
  function: (value) => typeof value === "function",
62
118
  buffer: (value) => Buffer.isBuffer(value) || (value instanceof Uint8Array),
@@ -71,9 +127,26 @@ export const validator = {
71
127
  }
72
128
 
73
129
  Object.entries(params).forEach(([param, config]) => {
74
- const { value, type, required = true, custom, message } = config;
130
+ const { value, type, required = true, custom, message, rejectNull } = config;
131
+
132
+ if (value === null && rejectNull) {
133
+ throw new ValidationError(
134
+ operation,
135
+ param,
136
+ type,
137
+ message ||
138
+ `[Vault SDK] '${operation}': Parameter '${param}' must be a valid ${type}, or be left out entirely. Received: null.`
139
+ );
140
+ }
141
+
142
+ // An optional string left empty means "not supplied"; the string type
143
+ // rejects "", so without this `getFiles(vaultId)` throws on its own default.
144
+ const omitted =
145
+ value === undefined ||
146
+ value === null ||
147
+ (type === "string" && typeof value === "string" && value.trim() === "");
75
148
 
76
- if ((value === undefined || value === null) && !required) {
149
+ if (omitted && !required) {
77
150
  return;
78
151
  }
79
152
 
@@ -88,11 +161,16 @@ export const validator = {
88
161
 
89
162
  const typeValidator = this.types[type];
90
163
  if (typeValidator && !typeValidator(value)) {
164
+ const blank =
165
+ type === "string" && typeof value === "string" && value.trim() === "";
91
166
  throw new ValidationError(
92
167
  operation,
93
168
  param,
94
169
  type,
95
- message || `[Vault SDK] '${operation}': Parameter '${param}' must be a valid ${type}. Received: ${typeof value}.`
170
+ message ||
171
+ (blank
172
+ ? `[Vault SDK] '${operation}' requires a non-empty '${param}'.`
173
+ : `[Vault SDK] '${operation}': Parameter '${param}' must be a valid ${type}. Received: ${describe(value)}.`)
96
174
  );
97
175
  }
98
176