@juspay/neurolink 9.95.3 → 10.0.0

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.
@@ -110,6 +110,103 @@ export declare function redactUrlCredentials(url: string): string;
110
110
  * @param url - The URL (or URL-shaped string) to redact.
111
111
  */
112
112
  export declare function redactUrlForError(url: string): string;
113
+ /**
114
+ * Query-parameter names that commonly carry authentication/signature
115
+ * secrets in presigned URLs, matched case-insensitively: generic
116
+ * `token`/`signature`, AWS SigV4 (`X-Amz-Signature`, `X-Amz-Credential`,
117
+ * `X-Amz-Security-Token`, and the legacy `AWSAccessKeyId`/`Signature`/
118
+ * `Expires` query-auth params), Azure SAS (`sig`/`se`/`sp`/`sr`/`sv`), and
119
+ * GCS V4 signed URLs (`X-Goog-Signature`, `X-Goog-Credential`,
120
+ * `X-Goog-Algorithm`, `X-Goog-Date`, `X-Goog-Expires`,
121
+ * `X-Goog-SignedHeaders`).
122
+ *
123
+ * Unlike {@link redactUrlForError} (which drops the *entire* query string
124
+ * for a one-off error message), this is used where a URL is normalized
125
+ * into a long-lived in-memory cache key — content-varying query params
126
+ * must survive so distinct resources still get distinct keys, but a
127
+ * presigned URL's secret must never persist as a Map key for the process
128
+ * lifetime.
129
+ */
130
+ export declare const SENSITIVE_URL_QUERY_PARAM_DENYLIST: readonly string[];
131
+ /**
132
+ * Strip common tracking/analytics query params (`utm_*`, `fbclid`, `gclid`,
133
+ * `_ga`) from `parsed` in place, so callers that build a cache key from a
134
+ * URL don't miss a cache hit solely because two otherwise-identical URLs
135
+ * carry different tracking noise.
136
+ *
137
+ * Shared by `ImageCache.normalizeUrl` and `fileDetector.cacheKeyForUrl` —
138
+ * both build a long-lived in-memory cache key from a URL and should
139
+ * normalize tracking params the same way. Call this before
140
+ * {@link stripSensitiveUrlParamsForCacheKey} so both normalizations apply
141
+ * consistently regardless of caller.
142
+ *
143
+ * @param parsed - A `URL` instance to strip tracking params from in place.
144
+ */
145
+ export declare function stripTrackingParams(parsed: URL): void;
146
+ /**
147
+ * Build a secret-free, but still auth-context-distinct, cache key from a
148
+ * URL that may carry presigned-URL secrets in its query string.
149
+ *
150
+ * Naively stripping {@link SENSITIVE_URL_QUERY_PARAM_DENYLIST} params before
151
+ * using a URL as a long-lived cache key (an earlier version of this code did
152
+ * exactly that) creates a correctness bug, not just a hygiene one: two
153
+ * *different* presigned URLs for the same underlying object — e.g. one
154
+ * whose signature has since expired or been revoked, and a fresh one —
155
+ * strip down to the *same* key and collide. A cache populated under the
156
+ * first would silently serve its bytes back for the second, without ever
157
+ * revalidating against the origin.
158
+ *
159
+ * Fix: still remove the sensitive params from `parsed` in place (so the
160
+ * plaintext key never retains a raw secret — the original goal), but fold a
161
+ * short, non-reversible hash of the *removed* key=value pairs into the
162
+ * returned suffix whenever any were present, so distinct auth contexts keep
163
+ * distinct cache keys. Two requests presenting the exact same secret value
164
+ * still collide (correct — that's genuinely the same authorized request);
165
+ * two different secret values for the same object no longer do.
166
+ *
167
+ * Callers combine the return value with the now-stripped `parsed.toString()`
168
+ * to form the final cache key (see `ImageCache.normalizeUrl` and
169
+ * `fileDetector.cacheKeyForUrl`), rather than this function returning the
170
+ * full key itself, so each caller can apply its own additional
171
+ * normalization (e.g. tracking-param removal) in between.
172
+ *
173
+ * Matching against {@link SENSITIVE_URL_QUERY_PARAM_DENYLIST} is
174
+ * case-insensitive (both the denylist lookup and the value folded into the
175
+ * hash lower-case the param name), so the original casing of a sensitive
176
+ * param's name is not preserved anywhere in the returned key. That's fine
177
+ * here — this produces a cache key, not a user-facing URL, and the same
178
+ * lower-cased name always hashes to the same suffix for the same value
179
+ * regardless of how the caller happened to case it (`Token=` vs `token=`).
180
+ * Non-sensitive params left on `parsed` keep their original casing untouched.
181
+ *
182
+ * @param parsed - A `URL` instance to strip sensitive params from in place.
183
+ * @returns "" when no sensitive params were present; otherwise a short hex
184
+ * suffix — prefixed with `\0` (a byte `URL#toString()` never emits, since
185
+ * every URL component is percent-encoded, so it can't collide with real
186
+ * URL content) — to append to the caller's cache key.
187
+ */
188
+ export declare function stripSensitiveUrlParamsForCacheKey(parsed: URL): string;
189
+ /**
190
+ * Build a secret-free, auth-context-distinct cache key from a URL, for any
191
+ * caller that needs a long-lived in-memory Map key derived from a URL.
192
+ *
193
+ * Centralizes the exact sequence both existing callers used independently:
194
+ * parse via `new URL()`, strip tracking noise ({@link stripTrackingParams}),
195
+ * then strip and hash-suffix sensitive auth params
196
+ * ({@link stripSensitiveUrlParamsForCacheKey}) so distinct presigned URLs for
197
+ * the same object don't collide (see that function's doc comment for why a
198
+ * naive strip-only approach is a correctness bug, not just a hygiene one).
199
+ *
200
+ * Shared by `ImageCache.normalizeUrl` and `fileDetector.cacheKeyForUrl` — both
201
+ * build a cache key from a URL and must normalize it identically, or the same
202
+ * URL could hash to two different keys depending on which cache looked it up.
203
+ *
204
+ * Falls back to the raw `url` string unchanged when it isn't a parseable
205
+ * absolute URL (mirrors both callers' pre-existing catch behavior).
206
+ *
207
+ * @param url - The URL (or URL-shaped string) to normalize into a cache key.
208
+ */
209
+ export declare function normalizeUrlForCache(url: string): string;
113
210
  /**
114
211
  * Scrub any embedded absolute URLs out of free-form text — typically the
115
212
  * `.message` of an underlying network/DNS/TLS error thrown by `fetch`,
@@ -15,6 +15,7 @@
15
15
  * jina_ (Jina), fish- (Fish Audio)
16
16
  * - Generic key=value pairs: api_key=…, access_token: …, secret_key=…
17
17
  */
18
+ import { createHash } from "crypto";
18
19
  import { basename, resolve as resolvePath } from "path";
19
20
  const TOKEN_PREFIXES = [
20
21
  "sk",
@@ -227,6 +228,168 @@ export function redactUrlForError(url) {
227
228
  return truncate(redactUrlCredentials(withoutQueryOrFragment));
228
229
  }
229
230
  }
231
+ /**
232
+ * Query-parameter names that commonly carry authentication/signature
233
+ * secrets in presigned URLs, matched case-insensitively: generic
234
+ * `token`/`signature`, AWS SigV4 (`X-Amz-Signature`, `X-Amz-Credential`,
235
+ * `X-Amz-Security-Token`, and the legacy `AWSAccessKeyId`/`Signature`/
236
+ * `Expires` query-auth params), Azure SAS (`sig`/`se`/`sp`/`sr`/`sv`), and
237
+ * GCS V4 signed URLs (`X-Goog-Signature`, `X-Goog-Credential`,
238
+ * `X-Goog-Algorithm`, `X-Goog-Date`, `X-Goog-Expires`,
239
+ * `X-Goog-SignedHeaders`).
240
+ *
241
+ * Unlike {@link redactUrlForError} (which drops the *entire* query string
242
+ * for a one-off error message), this is used where a URL is normalized
243
+ * into a long-lived in-memory cache key — content-varying query params
244
+ * must survive so distinct resources still get distinct keys, but a
245
+ * presigned URL's secret must never persist as a Map key for the process
246
+ * lifetime.
247
+ */
248
+ export const SENSITIVE_URL_QUERY_PARAM_DENYLIST = [
249
+ "token",
250
+ "signature",
251
+ "x-amz-signature",
252
+ "x-amz-credential",
253
+ "x-amz-security-token",
254
+ "awsaccesskeyid",
255
+ "expires",
256
+ "sig",
257
+ "se",
258
+ "sp",
259
+ "sr",
260
+ "sv",
261
+ "x-goog-signature",
262
+ "x-goog-credential",
263
+ "x-goog-algorithm",
264
+ "x-goog-date",
265
+ "x-goog-expires",
266
+ "x-goog-signedheaders",
267
+ ];
268
+ /** Common tracking/analytics query params that don't affect the fetched
269
+ * content, so two URLs differing only by these should map to the same
270
+ * cache key rather than missing each other. */
271
+ const TRACKING_URL_QUERY_PARAMS = [
272
+ "utm_source",
273
+ "utm_medium",
274
+ "utm_campaign",
275
+ "utm_term",
276
+ "utm_content",
277
+ "fbclid",
278
+ "gclid",
279
+ "_ga",
280
+ ];
281
+ /**
282
+ * Strip common tracking/analytics query params (`utm_*`, `fbclid`, `gclid`,
283
+ * `_ga`) from `parsed` in place, so callers that build a cache key from a
284
+ * URL don't miss a cache hit solely because two otherwise-identical URLs
285
+ * carry different tracking noise.
286
+ *
287
+ * Shared by `ImageCache.normalizeUrl` and `fileDetector.cacheKeyForUrl` —
288
+ * both build a long-lived in-memory cache key from a URL and should
289
+ * normalize tracking params the same way. Call this before
290
+ * {@link stripSensitiveUrlParamsForCacheKey} so both normalizations apply
291
+ * consistently regardless of caller.
292
+ *
293
+ * @param parsed - A `URL` instance to strip tracking params from in place.
294
+ */
295
+ export function stripTrackingParams(parsed) {
296
+ TRACKING_URL_QUERY_PARAMS.forEach((param) => parsed.searchParams.delete(param));
297
+ }
298
+ /**
299
+ * Build a secret-free, but still auth-context-distinct, cache key from a
300
+ * URL that may carry presigned-URL secrets in its query string.
301
+ *
302
+ * Naively stripping {@link SENSITIVE_URL_QUERY_PARAM_DENYLIST} params before
303
+ * using a URL as a long-lived cache key (an earlier version of this code did
304
+ * exactly that) creates a correctness bug, not just a hygiene one: two
305
+ * *different* presigned URLs for the same underlying object — e.g. one
306
+ * whose signature has since expired or been revoked, and a fresh one —
307
+ * strip down to the *same* key and collide. A cache populated under the
308
+ * first would silently serve its bytes back for the second, without ever
309
+ * revalidating against the origin.
310
+ *
311
+ * Fix: still remove the sensitive params from `parsed` in place (so the
312
+ * plaintext key never retains a raw secret — the original goal), but fold a
313
+ * short, non-reversible hash of the *removed* key=value pairs into the
314
+ * returned suffix whenever any were present, so distinct auth contexts keep
315
+ * distinct cache keys. Two requests presenting the exact same secret value
316
+ * still collide (correct — that's genuinely the same authorized request);
317
+ * two different secret values for the same object no longer do.
318
+ *
319
+ * Callers combine the return value with the now-stripped `parsed.toString()`
320
+ * to form the final cache key (see `ImageCache.normalizeUrl` and
321
+ * `fileDetector.cacheKeyForUrl`), rather than this function returning the
322
+ * full key itself, so each caller can apply its own additional
323
+ * normalization (e.g. tracking-param removal) in between.
324
+ *
325
+ * Matching against {@link SENSITIVE_URL_QUERY_PARAM_DENYLIST} is
326
+ * case-insensitive (both the denylist lookup and the value folded into the
327
+ * hash lower-case the param name), so the original casing of a sensitive
328
+ * param's name is not preserved anywhere in the returned key. That's fine
329
+ * here — this produces a cache key, not a user-facing URL, and the same
330
+ * lower-cased name always hashes to the same suffix for the same value
331
+ * regardless of how the caller happened to case it (`Token=` vs `token=`).
332
+ * Non-sensitive params left on `parsed` keep their original casing untouched.
333
+ *
334
+ * @param parsed - A `URL` instance to strip sensitive params from in place.
335
+ * @returns "" when no sensitive params were present; otherwise a short hex
336
+ * suffix — prefixed with `\0` (a byte `URL#toString()` never emits, since
337
+ * every URL component is percent-encoded, so it can't collide with real
338
+ * URL content) — to append to the caller's cache key.
339
+ */
340
+ export function stripSensitiveUrlParamsForCacheKey(parsed) {
341
+ const strippedPairs = [];
342
+ const keysToDelete = [];
343
+ for (const [key, value] of parsed.searchParams.entries()) {
344
+ if (SENSITIVE_URL_QUERY_PARAM_DENYLIST.includes(key.toLowerCase())) {
345
+ strippedPairs.push(`${key.toLowerCase()}=${value}`);
346
+ keysToDelete.push(key);
347
+ }
348
+ }
349
+ if (strippedPairs.length === 0) {
350
+ return "";
351
+ }
352
+ keysToDelete.forEach((key) => parsed.searchParams.delete(key));
353
+ // Sort so key order in the original URL doesn't affect the hash — the
354
+ // same *set* of secret values must always hash to the same suffix.
355
+ strippedPairs.sort();
356
+ const hash = createHash("sha256")
357
+ .update(strippedPairs.join("&"))
358
+ .digest("hex")
359
+ .slice(0, 16);
360
+ return `\0auth=${hash}`;
361
+ }
362
+ /**
363
+ * Build a secret-free, auth-context-distinct cache key from a URL, for any
364
+ * caller that needs a long-lived in-memory Map key derived from a URL.
365
+ *
366
+ * Centralizes the exact sequence both existing callers used independently:
367
+ * parse via `new URL()`, strip tracking noise ({@link stripTrackingParams}),
368
+ * then strip and hash-suffix sensitive auth params
369
+ * ({@link stripSensitiveUrlParamsForCacheKey}) so distinct presigned URLs for
370
+ * the same object don't collide (see that function's doc comment for why a
371
+ * naive strip-only approach is a correctness bug, not just a hygiene one).
372
+ *
373
+ * Shared by `ImageCache.normalizeUrl` and `fileDetector.cacheKeyForUrl` — both
374
+ * build a cache key from a URL and must normalize it identically, or the same
375
+ * URL could hash to two different keys depending on which cache looked it up.
376
+ *
377
+ * Falls back to the raw `url` string unchanged when it isn't a parseable
378
+ * absolute URL (mirrors both callers' pre-existing catch behavior).
379
+ *
380
+ * @param url - The URL (or URL-shaped string) to normalize into a cache key.
381
+ */
382
+ export function normalizeUrlForCache(url) {
383
+ try {
384
+ const parsed = new URL(url);
385
+ stripTrackingParams(parsed);
386
+ const authSuffix = stripSensitiveUrlParamsForCacheKey(parsed);
387
+ return parsed.toString() + authSuffix;
388
+ }
389
+ catch {
390
+ return url;
391
+ }
392
+ }
230
393
  /**
231
394
  * Scrub any embedded absolute URLs out of free-form text — typically the
232
395
  * `.message` of an underlying network/DNS/TLS error thrown by `fetch`,
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, statSync } from "fs";
2
2
  import { readFile as readFileAsync, stat as statAsync } from "fs/promises";
3
+ import { basename } from "path";
3
4
  import { getGlobalDispatcher, interceptors, request } from "undici";
4
5
  import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerImageAdapter.js";
5
6
  import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
@@ -1031,8 +1032,26 @@ async function processExplicitPdfFiles(options, maxSize, provider) {
1031
1032
  // it in aggregate (e.g. three 40-page PDFs → 120 pages for a 100-page API).
1032
1033
  const aggregateConfig = PDFProcessor.getProviderConfig(provider);
1033
1034
  if (aggregateConfig && pdfFiles.length > 1) {
1035
+ // A null pageCount (accurate count unavailable — see
1036
+ // PDFProcessor.getAccuratePageCount) is treated as 0 in the sum below,
1037
+ // which can undercount the aggregate and let a combined request over
1038
+ // the provider's page limit slip through silently. Enforcement still
1039
+ // runs against the known sum — a PDF with an unknown count must not
1040
+ // fail the request outright — but the gap itself must not be silent.
1041
+ const unknownPageCountFiles = pdfFiles.filter((f) => f.pageCount === null || f.pageCount === undefined);
1034
1042
  const totalPages = pdfFiles.reduce((sum, f) => sum + (f.pageCount ?? 0), 0);
1035
1043
  const totalMB = pdfFiles.reduce((sum, f) => sum + f.buffer.length, 0) / (1024 * 1024);
1044
+ if (unknownPageCountFiles.length > 0) {
1045
+ // Filenames are caller-controlled and may be full paths carrying
1046
+ // PII/internal directory segments — log only the basename.
1047
+ const unknownFileNames = unknownPageCountFiles
1048
+ .map((f) => (f.filename ? basename(f.filename) : "<unnamed file>"))
1049
+ .join(", ");
1050
+ logger.warn(`[PDF] Aggregate page-limit check across ${pdfFiles.length} PDFs could only be ` +
1051
+ `partially verified: ${unknownPageCountFiles.length} file(s) have an unknown ` +
1052
+ `page count (${unknownFileNames}), so the known total (${totalPages}) may ` +
1053
+ `undercount the true combined page count.`);
1054
+ }
1036
1055
  if (totalPages > aggregateConfig.maxPages) {
1037
1056
  throw new Error(`[PDF] Combined page count across ${pdfFiles.length} PDFs (${totalPages}) exceeds the ` +
1038
1057
  `${aggregateConfig.maxPages}-page limit for ${provider}. ` +
@@ -81,7 +81,13 @@ export declare class PDFProcessor {
81
81
  * base64 PNG as soon as it renders instead of buffering the whole document,
82
82
  * and reports progress via `options.onProgress`. A page that fails to render
83
83
  * is yielded with `error` set (per-page isolation, #294) rather than aborting
84
- * the stream. `convertToImages` is the batch wrapper over this contract.
84
+ * the stream.
85
+ *
86
+ * NOT a wrapper of/over {@link convertToImages}, despite the similar
87
+ * contract — the two are independent, parallel implementations (each does
88
+ * its own `pdf-to-img` import, downscale calculation, and page loop)
89
+ * rather than one delegating to the other. Keep behavior changes (page
90
+ * isolation, downscale, password handling) in sync across both by hand.
85
91
  */
86
92
  static convertToImagesStream(pdfBuffer: Buffer, options?: PDFImageConversionOptions): AsyncGenerator<PDFImagePage, void, void>;
87
93
  /**
@@ -243,16 +243,34 @@ export class PDFProcessor {
243
243
  try {
244
244
  const { PDFParse } = await import("pdf-parse");
245
245
  const pdf = new PDFParse({ data: new Uint8Array(buffer) });
246
+ // Captured outside the try so the `finally` below can always clear it
247
+ // — otherwise a `getInfo()` that wins the race leaves this timer
248
+ // running until PAGE_COUNT_TIMEOUT_MS fires for nothing. `.unref()`
249
+ // so it can never itself hold the process open in the meantime.
250
+ let timeoutHandle;
246
251
  try {
247
252
  const info = await Promise.race([
248
253
  pdf.getInfo(),
249
- new Promise((_, reject) => setTimeout(() => reject(new Error("page-count timeout")), PDF_LIMITS.PAGE_COUNT_TIMEOUT_MS)),
254
+ new Promise((_, reject) => {
255
+ timeoutHandle = setTimeout(() => reject(new Error("page-count timeout")), PDF_LIMITS.PAGE_COUNT_TIMEOUT_MS);
256
+ timeoutHandle.unref?.();
257
+ }),
250
258
  ]);
251
259
  const total = info.total;
252
260
  return typeof total === "number" && total > 0 ? total : null;
253
261
  }
254
262
  finally {
255
- await pdf.destroy?.();
263
+ if (timeoutHandle !== undefined) {
264
+ clearTimeout(timeoutHandle);
265
+ }
266
+ try {
267
+ await pdf.destroy?.();
268
+ }
269
+ catch {
270
+ // A throwing destroy() must not discard an otherwise-valid page
271
+ // count returned above (matches fileReferenceRegistry.ts's
272
+ // pdf.destroy() cleanup pattern) — swallow it.
273
+ }
256
274
  }
257
275
  }
258
276
  catch (error) {
@@ -518,7 +536,13 @@ export class PDFProcessor {
518
536
  * base64 PNG as soon as it renders instead of buffering the whole document,
519
537
  * and reports progress via `options.onProgress`. A page that fails to render
520
538
  * is yielded with `error` set (per-page isolation, #294) rather than aborting
521
- * the stream. `convertToImages` is the batch wrapper over this contract.
539
+ * the stream.
540
+ *
541
+ * NOT a wrapper of/over {@link convertToImages}, despite the similar
542
+ * contract — the two are independent, parallel implementations (each does
543
+ * its own `pdf-to-img` import, downscale calculation, and page loop)
544
+ * rather than one delegating to the other. Keep behavior changes (page
545
+ * isolation, downscale, password handling) in sync across both by hand.
522
546
  */
523
547
  static async *convertToImagesStream(pdfBuffer, options) {
524
548
  const startTime = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "9.95.3",
3
+ "version": "10.0.0",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {