@juspay/neurolink 9.88.2 → 9.88.4
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/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +357 -357
- package/dist/lib/types/file.d.ts +10 -0
- package/dist/lib/utils/fileDetector.js +70 -14
- package/dist/types/file.d.ts +10 -0
- package/dist/utils/fileDetector.js +70 -14
- package/package.json +1 -1
package/dist/lib/types/file.d.ts
CHANGED
|
@@ -298,6 +298,16 @@ export type FileDetectorOptions = {
|
|
|
298
298
|
maxSize?: number;
|
|
299
299
|
timeout?: number;
|
|
300
300
|
allowedTypes?: FileType[];
|
|
301
|
+
/**
|
|
302
|
+
* When set, local file paths must resolve inside this base directory;
|
|
303
|
+
* anything that escapes it (absolute path, `../` traversal, or a symlink
|
|
304
|
+
* pointing outside) is rejected. Containment is enforced on the real,
|
|
305
|
+
* symlink-resolved path of both the base dir and the target, so a symlink
|
|
306
|
+
* inside the base cannot be used to reach a file outside it. Servers that
|
|
307
|
+
* accept file paths from untrusted callers should set this to sandbox
|
|
308
|
+
* filesystem access; SDK callers loading their own files can omit it.
|
|
309
|
+
*/
|
|
310
|
+
allowedBaseDir?: string;
|
|
301
311
|
audioOptions?: AudioProcessorOptions;
|
|
302
312
|
csvOptions?: CSVProcessorOptions;
|
|
303
313
|
officeOptions?: OfficeProcessorOptions;
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Centralized file detection for all multimodal file types
|
|
4
4
|
* Uses multi-strategy approach for reliable type identification
|
|
5
5
|
*/
|
|
6
|
-
import { readFile,
|
|
6
|
+
import { open, readFile, realpath } from "fs/promises";
|
|
7
|
+
import { isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
|
|
7
8
|
import { getGlobalDispatcher, interceptors, request } from "undici";
|
|
8
9
|
// Lazy-loaded processor singletons — avoids loading heavy media deps
|
|
9
10
|
// (mediabunny, fluent-ffmpeg, music-metadata, adm-zip) on every generate() call.
|
|
@@ -585,7 +586,9 @@ export class FileDetector {
|
|
|
585
586
|
return result;
|
|
586
587
|
}
|
|
587
588
|
}
|
|
588
|
-
|
|
589
|
+
// Below-threshold detection is the common case for any file under the
|
|
590
|
+
// ContentHeuristic ceiling — a debug detail, not a warning-worthy anomaly.
|
|
591
|
+
logger.debug(`[FileDetector] Best-effort type below threshold: ${best?.type ?? "unknown"} (${best?.metadata.confidence ?? 0}%, threshold ${confidenceThreshold}%)`);
|
|
589
592
|
return best;
|
|
590
593
|
}
|
|
591
594
|
/**
|
|
@@ -1342,7 +1345,7 @@ export class FileDetector {
|
|
|
1342
1345
|
bodyTimeout: timeout,
|
|
1343
1346
|
});
|
|
1344
1347
|
if (response.statusCode !== 200) {
|
|
1345
|
-
throw new Error(`HTTP ${response.statusCode}`);
|
|
1348
|
+
throw new Error(`HTTP ${response.statusCode} fetching ${url}`);
|
|
1346
1349
|
}
|
|
1347
1350
|
const chunks = [];
|
|
1348
1351
|
let totalSize = 0;
|
|
@@ -1359,16 +1362,49 @@ export class FileDetector {
|
|
|
1359
1362
|
/**
|
|
1360
1363
|
* Load file from filesystem path
|
|
1361
1364
|
*/
|
|
1362
|
-
static async loadFromPath(
|
|
1365
|
+
static async loadFromPath(filePath, options) {
|
|
1363
1366
|
const maxSize = options?.maxSize || 200 * 1024 * 1024; // 200MB default (matches Curator memory-safety cap)
|
|
1364
|
-
|
|
1365
|
-
if (
|
|
1366
|
-
throw new Error("
|
|
1367
|
+
// Reject NUL-byte injection outright (a classic path-truncation vector).
|
|
1368
|
+
if (filePath.includes("\0")) {
|
|
1369
|
+
throw new Error("Invalid file path: contains a null byte");
|
|
1370
|
+
}
|
|
1371
|
+
// Optional sandbox: when a base dir is configured (servers accepting paths
|
|
1372
|
+
// from untrusted callers), reject anything that resolves outside it. Real
|
|
1373
|
+
// paths are resolved (symlinks followed) on BOTH sides so a symlink inside
|
|
1374
|
+
// the base dir pointing outside cannot bypass containment. The
|
|
1375
|
+
// path.relative check (not a string prefix) correctly handles the root dir
|
|
1376
|
+
// ("/") and sibling-prefix ("/app" vs "/app-evil") edge cases.
|
|
1377
|
+
if (options?.allowedBaseDir) {
|
|
1378
|
+
let base;
|
|
1379
|
+
let real;
|
|
1380
|
+
try {
|
|
1381
|
+
base = await realpath(resolvePath(options.allowedBaseDir));
|
|
1382
|
+
real = await realpath(filePath);
|
|
1383
|
+
}
|
|
1384
|
+
catch {
|
|
1385
|
+
throw new Error(`Access denied: "${filePath}" could not be resolved within the allowed base directory`);
|
|
1386
|
+
}
|
|
1387
|
+
const rel = relativePath(base, real);
|
|
1388
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolutePath(rel)) {
|
|
1389
|
+
throw new Error(`Access denied: "${filePath}" resolves outside the allowed base directory`);
|
|
1390
|
+
}
|
|
1367
1391
|
}
|
|
1368
|
-
|
|
1369
|
-
|
|
1392
|
+
// Open a handle and stat/read through the SAME descriptor so a symlink
|
|
1393
|
+
// swap between the size check and the read cannot occur (TOCTOU).
|
|
1394
|
+
const handle = await open(filePath, "r");
|
|
1395
|
+
try {
|
|
1396
|
+
const statInfo = await handle.stat();
|
|
1397
|
+
if (!statInfo.isFile()) {
|
|
1398
|
+
throw new Error(`Not a file: ${filePath}`);
|
|
1399
|
+
}
|
|
1400
|
+
if (statInfo.size > maxSize) {
|
|
1401
|
+
throw new Error(`File too large: ${filePath} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
|
|
1402
|
+
}
|
|
1403
|
+
return await handle.readFile();
|
|
1404
|
+
}
|
|
1405
|
+
finally {
|
|
1406
|
+
await handle.close();
|
|
1370
1407
|
}
|
|
1371
|
-
return await readFile(path);
|
|
1372
1408
|
}
|
|
1373
1409
|
/**
|
|
1374
1410
|
* Load file from data URI
|
|
@@ -1376,7 +1412,7 @@ export class FileDetector {
|
|
|
1376
1412
|
static loadFromDataURI(dataUri) {
|
|
1377
1413
|
const match = dataUri.match(/^data:([^;]+);base64,(.+)$/);
|
|
1378
1414
|
if (!match) {
|
|
1379
|
-
throw new Error(
|
|
1415
|
+
throw new Error(`Invalid data URI format (expected "data:<mime>;base64,<data>"): "${dataUri.slice(0, 32)}…"`);
|
|
1380
1416
|
}
|
|
1381
1417
|
return Buffer.from(match[2], "base64");
|
|
1382
1418
|
}
|
|
@@ -1405,20 +1441,34 @@ class MagicBytesStrategy {
|
|
|
1405
1441
|
if (this.isPDF(input)) {
|
|
1406
1442
|
return this.result("pdf", "application/pdf", 95);
|
|
1407
1443
|
}
|
|
1408
|
-
//
|
|
1444
|
+
// ISO-BMFF ("ftyp" at offset 4): MP4 video, QuickTime MOV, or M4A/M4B/M4P
|
|
1445
|
+
// audio all share this box — disambiguate by the major brand at offset 8-11,
|
|
1446
|
+
// otherwise an .m4a audio file is misrouted to the video pipeline.
|
|
1409
1447
|
if (input.length >= 8 &&
|
|
1410
1448
|
input[4] === 0x66 &&
|
|
1411
1449
|
input[5] === 0x74 &&
|
|
1412
1450
|
input[6] === 0x79 &&
|
|
1413
1451
|
input[7] === 0x70) {
|
|
1452
|
+
const brand = input.length >= 12 ? input.toString("latin1", 8, 12) : "";
|
|
1453
|
+
if (/^(M4A|M4B|M4P|F4A|F4B)/.test(brand)) {
|
|
1454
|
+
return this.result("audio", "audio/mp4", 95);
|
|
1455
|
+
}
|
|
1456
|
+
if (brand.startsWith("qt")) {
|
|
1457
|
+
return this.result("video", "video/quicktime", 95);
|
|
1458
|
+
}
|
|
1414
1459
|
return this.result("video", "video/mp4", 95);
|
|
1415
1460
|
}
|
|
1416
|
-
// MKV/WebM
|
|
1461
|
+
// EBML container (MKV/WebM) — both share the 0x1A45DFA3 header; the DocType
|
|
1462
|
+
// string in the header disambiguates WebM from generic Matroska.
|
|
1417
1463
|
if (input.length >= 4 &&
|
|
1418
1464
|
input[0] === 0x1a &&
|
|
1419
1465
|
input[1] === 0x45 &&
|
|
1420
1466
|
input[2] === 0xdf &&
|
|
1421
1467
|
input[3] === 0xa3) {
|
|
1468
|
+
const head = input.toString("latin1", 0, Math.min(input.length, 64));
|
|
1469
|
+
if (head.includes("webm")) {
|
|
1470
|
+
return this.result("video", "video/webm", 92);
|
|
1471
|
+
}
|
|
1422
1472
|
return this.result("video", "video/x-matroska", 90);
|
|
1423
1473
|
}
|
|
1424
1474
|
// AVI: "RIFF" + "AVI "
|
|
@@ -1452,7 +1502,13 @@ class MagicBytesStrategy {
|
|
|
1452
1502
|
input[2] === 0x33) {
|
|
1453
1503
|
return this.result("audio", "audio/mpeg", 95);
|
|
1454
1504
|
}
|
|
1455
|
-
//
|
|
1505
|
+
// AAC (ADTS): 12-bit syncword 0xFFF with the 2 layer bits == 00. This must be
|
|
1506
|
+
// checked before the MP3 sync word below, because an ADTS header also satisfies
|
|
1507
|
+
// the looser 11-bit MPEG sync mask and would otherwise be mislabeled audio/mpeg.
|
|
1508
|
+
if (input.length >= 2 && input[0] === 0xff && (input[1] & 0xf6) === 0xf0) {
|
|
1509
|
+
return this.result("audio", "audio/aac", 85);
|
|
1510
|
+
}
|
|
1511
|
+
// MP3: sync word (MPEG audio — layer bits are non-zero, unlike ADTS AAC above)
|
|
1456
1512
|
if (input.length >= 2 && input[0] === 0xff && (input[1] & 0xe0) === 0xe0) {
|
|
1457
1513
|
return this.result("audio", "audio/mpeg", 80);
|
|
1458
1514
|
}
|
package/dist/types/file.d.ts
CHANGED
|
@@ -298,6 +298,16 @@ export type FileDetectorOptions = {
|
|
|
298
298
|
maxSize?: number;
|
|
299
299
|
timeout?: number;
|
|
300
300
|
allowedTypes?: FileType[];
|
|
301
|
+
/**
|
|
302
|
+
* When set, local file paths must resolve inside this base directory;
|
|
303
|
+
* anything that escapes it (absolute path, `../` traversal, or a symlink
|
|
304
|
+
* pointing outside) is rejected. Containment is enforced on the real,
|
|
305
|
+
* symlink-resolved path of both the base dir and the target, so a symlink
|
|
306
|
+
* inside the base cannot be used to reach a file outside it. Servers that
|
|
307
|
+
* accept file paths from untrusted callers should set this to sandbox
|
|
308
|
+
* filesystem access; SDK callers loading their own files can omit it.
|
|
309
|
+
*/
|
|
310
|
+
allowedBaseDir?: string;
|
|
301
311
|
audioOptions?: AudioProcessorOptions;
|
|
302
312
|
csvOptions?: CSVProcessorOptions;
|
|
303
313
|
officeOptions?: OfficeProcessorOptions;
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Centralized file detection for all multimodal file types
|
|
4
4
|
* Uses multi-strategy approach for reliable type identification
|
|
5
5
|
*/
|
|
6
|
-
import { readFile,
|
|
6
|
+
import { open, readFile, realpath } from "fs/promises";
|
|
7
|
+
import { isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
|
|
7
8
|
import { getGlobalDispatcher, interceptors, request } from "undici";
|
|
8
9
|
// Lazy-loaded processor singletons — avoids loading heavy media deps
|
|
9
10
|
// (mediabunny, fluent-ffmpeg, music-metadata, adm-zip) on every generate() call.
|
|
@@ -585,7 +586,9 @@ export class FileDetector {
|
|
|
585
586
|
return result;
|
|
586
587
|
}
|
|
587
588
|
}
|
|
588
|
-
|
|
589
|
+
// Below-threshold detection is the common case for any file under the
|
|
590
|
+
// ContentHeuristic ceiling — a debug detail, not a warning-worthy anomaly.
|
|
591
|
+
logger.debug(`[FileDetector] Best-effort type below threshold: ${best?.type ?? "unknown"} (${best?.metadata.confidence ?? 0}%, threshold ${confidenceThreshold}%)`);
|
|
589
592
|
return best;
|
|
590
593
|
}
|
|
591
594
|
/**
|
|
@@ -1342,7 +1345,7 @@ export class FileDetector {
|
|
|
1342
1345
|
bodyTimeout: timeout,
|
|
1343
1346
|
});
|
|
1344
1347
|
if (response.statusCode !== 200) {
|
|
1345
|
-
throw new Error(`HTTP ${response.statusCode}`);
|
|
1348
|
+
throw new Error(`HTTP ${response.statusCode} fetching ${url}`);
|
|
1346
1349
|
}
|
|
1347
1350
|
const chunks = [];
|
|
1348
1351
|
let totalSize = 0;
|
|
@@ -1359,16 +1362,49 @@ export class FileDetector {
|
|
|
1359
1362
|
/**
|
|
1360
1363
|
* Load file from filesystem path
|
|
1361
1364
|
*/
|
|
1362
|
-
static async loadFromPath(
|
|
1365
|
+
static async loadFromPath(filePath, options) {
|
|
1363
1366
|
const maxSize = options?.maxSize || 200 * 1024 * 1024; // 200MB default (matches Curator memory-safety cap)
|
|
1364
|
-
|
|
1365
|
-
if (
|
|
1366
|
-
throw new Error("
|
|
1367
|
+
// Reject NUL-byte injection outright (a classic path-truncation vector).
|
|
1368
|
+
if (filePath.includes("\0")) {
|
|
1369
|
+
throw new Error("Invalid file path: contains a null byte");
|
|
1370
|
+
}
|
|
1371
|
+
// Optional sandbox: when a base dir is configured (servers accepting paths
|
|
1372
|
+
// from untrusted callers), reject anything that resolves outside it. Real
|
|
1373
|
+
// paths are resolved (symlinks followed) on BOTH sides so a symlink inside
|
|
1374
|
+
// the base dir pointing outside cannot bypass containment. The
|
|
1375
|
+
// path.relative check (not a string prefix) correctly handles the root dir
|
|
1376
|
+
// ("/") and sibling-prefix ("/app" vs "/app-evil") edge cases.
|
|
1377
|
+
if (options?.allowedBaseDir) {
|
|
1378
|
+
let base;
|
|
1379
|
+
let real;
|
|
1380
|
+
try {
|
|
1381
|
+
base = await realpath(resolvePath(options.allowedBaseDir));
|
|
1382
|
+
real = await realpath(filePath);
|
|
1383
|
+
}
|
|
1384
|
+
catch {
|
|
1385
|
+
throw new Error(`Access denied: "${filePath}" could not be resolved within the allowed base directory`);
|
|
1386
|
+
}
|
|
1387
|
+
const rel = relativePath(base, real);
|
|
1388
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolutePath(rel)) {
|
|
1389
|
+
throw new Error(`Access denied: "${filePath}" resolves outside the allowed base directory`);
|
|
1390
|
+
}
|
|
1367
1391
|
}
|
|
1368
|
-
|
|
1369
|
-
|
|
1392
|
+
// Open a handle and stat/read through the SAME descriptor so a symlink
|
|
1393
|
+
// swap between the size check and the read cannot occur (TOCTOU).
|
|
1394
|
+
const handle = await open(filePath, "r");
|
|
1395
|
+
try {
|
|
1396
|
+
const statInfo = await handle.stat();
|
|
1397
|
+
if (!statInfo.isFile()) {
|
|
1398
|
+
throw new Error(`Not a file: ${filePath}`);
|
|
1399
|
+
}
|
|
1400
|
+
if (statInfo.size > maxSize) {
|
|
1401
|
+
throw new Error(`File too large: ${filePath} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
|
|
1402
|
+
}
|
|
1403
|
+
return await handle.readFile();
|
|
1404
|
+
}
|
|
1405
|
+
finally {
|
|
1406
|
+
await handle.close();
|
|
1370
1407
|
}
|
|
1371
|
-
return await readFile(path);
|
|
1372
1408
|
}
|
|
1373
1409
|
/**
|
|
1374
1410
|
* Load file from data URI
|
|
@@ -1376,7 +1412,7 @@ export class FileDetector {
|
|
|
1376
1412
|
static loadFromDataURI(dataUri) {
|
|
1377
1413
|
const match = dataUri.match(/^data:([^;]+);base64,(.+)$/);
|
|
1378
1414
|
if (!match) {
|
|
1379
|
-
throw new Error(
|
|
1415
|
+
throw new Error(`Invalid data URI format (expected "data:<mime>;base64,<data>"): "${dataUri.slice(0, 32)}…"`);
|
|
1380
1416
|
}
|
|
1381
1417
|
return Buffer.from(match[2], "base64");
|
|
1382
1418
|
}
|
|
@@ -1405,20 +1441,34 @@ class MagicBytesStrategy {
|
|
|
1405
1441
|
if (this.isPDF(input)) {
|
|
1406
1442
|
return this.result("pdf", "application/pdf", 95);
|
|
1407
1443
|
}
|
|
1408
|
-
//
|
|
1444
|
+
// ISO-BMFF ("ftyp" at offset 4): MP4 video, QuickTime MOV, or M4A/M4B/M4P
|
|
1445
|
+
// audio all share this box — disambiguate by the major brand at offset 8-11,
|
|
1446
|
+
// otherwise an .m4a audio file is misrouted to the video pipeline.
|
|
1409
1447
|
if (input.length >= 8 &&
|
|
1410
1448
|
input[4] === 0x66 &&
|
|
1411
1449
|
input[5] === 0x74 &&
|
|
1412
1450
|
input[6] === 0x79 &&
|
|
1413
1451
|
input[7] === 0x70) {
|
|
1452
|
+
const brand = input.length >= 12 ? input.toString("latin1", 8, 12) : "";
|
|
1453
|
+
if (/^(M4A|M4B|M4P|F4A|F4B)/.test(brand)) {
|
|
1454
|
+
return this.result("audio", "audio/mp4", 95);
|
|
1455
|
+
}
|
|
1456
|
+
if (brand.startsWith("qt")) {
|
|
1457
|
+
return this.result("video", "video/quicktime", 95);
|
|
1458
|
+
}
|
|
1414
1459
|
return this.result("video", "video/mp4", 95);
|
|
1415
1460
|
}
|
|
1416
|
-
// MKV/WebM
|
|
1461
|
+
// EBML container (MKV/WebM) — both share the 0x1A45DFA3 header; the DocType
|
|
1462
|
+
// string in the header disambiguates WebM from generic Matroska.
|
|
1417
1463
|
if (input.length >= 4 &&
|
|
1418
1464
|
input[0] === 0x1a &&
|
|
1419
1465
|
input[1] === 0x45 &&
|
|
1420
1466
|
input[2] === 0xdf &&
|
|
1421
1467
|
input[3] === 0xa3) {
|
|
1468
|
+
const head = input.toString("latin1", 0, Math.min(input.length, 64));
|
|
1469
|
+
if (head.includes("webm")) {
|
|
1470
|
+
return this.result("video", "video/webm", 92);
|
|
1471
|
+
}
|
|
1422
1472
|
return this.result("video", "video/x-matroska", 90);
|
|
1423
1473
|
}
|
|
1424
1474
|
// AVI: "RIFF" + "AVI "
|
|
@@ -1452,7 +1502,13 @@ class MagicBytesStrategy {
|
|
|
1452
1502
|
input[2] === 0x33) {
|
|
1453
1503
|
return this.result("audio", "audio/mpeg", 95);
|
|
1454
1504
|
}
|
|
1455
|
-
//
|
|
1505
|
+
// AAC (ADTS): 12-bit syncword 0xFFF with the 2 layer bits == 00. This must be
|
|
1506
|
+
// checked before the MP3 sync word below, because an ADTS header also satisfies
|
|
1507
|
+
// the looser 11-bit MPEG sync mask and would otherwise be mislabeled audio/mpeg.
|
|
1508
|
+
if (input.length >= 2 && input[0] === 0xff && (input[1] & 0xf6) === 0xf0) {
|
|
1509
|
+
return this.result("audio", "audio/aac", 85);
|
|
1510
|
+
}
|
|
1511
|
+
// MP3: sync word (MPEG audio — layer bits are non-zero, unlike ADTS AAC above)
|
|
1456
1512
|
if (input.length >= 2 && input[0] === 0xff && (input[1] & 0xe0) === 0xe0) {
|
|
1457
1513
|
return this.result("audio", "audio/mpeg", 80);
|
|
1458
1514
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "9.88.
|
|
3
|
+
"version": "9.88.4",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (58+ servers), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|