@retrivora-ai/rag-engine 2.2.9 → 2.3.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.
@@ -5,16 +5,38 @@ import { ChatMessage, ChatResponse } from '../types';
5
5
  import { DocumentParser } from '../utils/DocumentParser';
6
6
  import { UITransformer } from '../utils/UITransformer';
7
7
  import { DatabaseStorage, HistoryMessage } from '../core/DatabaseStorage';
8
- import { LicenseVerifier } from '../core/LicenseVerifier';
8
+ import { LicenseVerifier, LicensePayload } from '../core/LicenseVerifier';
9
9
  import { SDK_VERSION } from '../version';
10
+ import { FreeTierLimitsGuard, FREE_TIER_QUOTAS } from '../core/FreeTierLimitsGuard';
10
11
 
12
+ /**
13
+ * Shared options accepted by every handler factory.
14
+ *
15
+ * Pass `onAuthorize` to plug in per-request auth rules; the callback is
16
+ * called before rate-limiting, license enforcement, or any business logic
17
+ * runs. Return `true` or the resolved promise `true` to allow, `false`
18
+ * to get a 401, or return a `Response` to fully control the denial (for
19
+ * example redirecting an OAuth-style flow to a login page).
20
+ */
11
21
  export interface HandlerOptions {
12
- onAuthorize?: (req: NextRequest) => boolean | Promise<boolean> | Response | Promise<Response>;
22
+ /** Optional synchronous or asynchronous authorization gate. */
23
+ onAuthorize?: (req: NextRequest) => boolean | Response | Promise<boolean | Response>;
24
+ /**
25
+ * Optional scope resolver for multi-tenant deployments.
26
+ *
27
+ * Returns an allow-list of project ids that the caller is authorized to
28
+ * access on the `sessions`, `history`, and `feedback` endpoints. When
29
+ * the callback returns a non-empty array, server-side filtering is
30
+ * applied to every `listSessions`, `getHistory`, `clearHistory`,
31
+ * `listFeedback`, and `getFeedback` call so the caller can never leak
32
+ * another tenant's data even when client-side filters are tampered with.
33
+ */
34
+ onResolveScope?: (req: NextRequest) => string[] | undefined | Promise<string[] | undefined>;
13
35
  }
14
36
 
15
37
  async function checkAuth(
16
38
  req: NextRequest,
17
- onAuthorize?: (req: NextRequest) => boolean | Promise<boolean> | Response | Promise<Response>
39
+ onAuthorize?: (req: NextRequest) => boolean | Response | Promise<boolean | Response>
18
40
  ): Promise<Response | null> {
19
41
  if (!onAuthorize) return null;
20
42
  try {
@@ -109,6 +131,115 @@ function checkRateLimit(req: NextRequest): Response | null {
109
131
  return null;
110
132
  }
111
133
 
134
+ // ─── Free Tier Limits Guard ───────────────────────────────────────────────────
135
+
136
+ const FREE_TIER_NAMES = new Set(['hobby', 'free', 'free_trial', 'trial']);
137
+
138
+ function isFreeTier(tier?: string | null): boolean {
139
+ if (!tier) return true;
140
+ return FREE_TIER_NAMES.has(tier.toLowerCase().trim());
141
+ }
142
+
143
+ const _freeTierGuardInstance: any = _g.__retrivoraFreeTierGuard ??
144
+ (_g.__retrivoraFreeTierGuard = new (FreeTierLimitsGuard as any)());
145
+
146
+ function createPaymentRequiredResponse(reason: string, details?: Record<string, unknown>): Response {
147
+ const body = {
148
+ type: 'https://retrivora.com/docs/errors/payment-required',
149
+ title: 'Payment Required',
150
+ status: 402,
151
+ detail: reason,
152
+ code: 'FREE_TIER_LIMIT_EXCEEDED',
153
+ instance: undefined as string | undefined,
154
+ ...(details ?? {}),
155
+ quota: {
156
+ maxDailyRequestUnits: FREE_TIER_QUOTAS.MAX_DAILY_REQUEST_UNITS,
157
+ maxTrialRequestUnits: FREE_TIER_QUOTAS.MAX_TRIAL_REQUEST_UNITS,
158
+ maxDocuments: FREE_TIER_QUOTAS.MAX_DOCUMENTS,
159
+ maxStorageBytes: FREE_TIER_QUOTAS.MAX_STORAGE_BYTES,
160
+ upgradeUrl: 'https://retrivora.com/pricing',
161
+ },
162
+ };
163
+ return new Response(JSON.stringify(body), {
164
+ status: 402,
165
+ headers: {
166
+ 'Content-Type': 'application/problem+json',
167
+ },
168
+ });
169
+ }
170
+
171
+ // ─── License Enforcement ──────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Resolve the license key from a request, trying (in priority order):
175
+ * 1. `x-license-key` header
176
+ * 2. Body property `licenseKey` (already-parsed body, optional)
177
+ * 3. `Authorization: Bearer <key>` header
178
+ * 4. SDK/plugin config and environment variables
179
+ */
180
+ function resolveLicenseKey(
181
+ req: NextRequest,
182
+ config: RagConfig,
183
+ bodyLicenseKey?: string
184
+ ): string {
185
+ return (
186
+ req.headers.get('x-license-key') ||
187
+ bodyLicenseKey ||
188
+ req.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ||
189
+ config.licenseKey ||
190
+ process.env.NEXT_PUBLIC_RETRIVORA_LICENSE_KEY ||
191
+ process.env.RETRIVORA_LICENSE_KEY ||
192
+ process.env.RAG_LICENSE_KEY ||
193
+ ''
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Enforce a valid Retrivora license key.
199
+ *
200
+ * - Extracts license key from headers / body / env / config
201
+ * - Performs cryptographic + expiration validation via `LicenseVerifier.verify`
202
+ * - Returns `null` when validation passes (contains the decoded payload for future tier checks)
203
+ * - Returns a 403 Response ready to be sent when validation fails
204
+ *
205
+ * In development (`NODE_ENV !== 'production'`) a missing key still produces a
206
+ * console warning but passes (fail-open local-dev behavior); in production any
207
+ * invalid / expired / missing key produces a hard 403.
208
+ */
209
+ function enforceLicense(
210
+ req: NextRequest,
211
+ config: RagConfig,
212
+ bodyLicenseKey?: string
213
+ ): { ok: true; payload: LicensePayload } | { ok: false; response: Response } {
214
+ const rawKey = resolveLicenseKey(req, config, bodyLicenseKey);
215
+ const licenseKey = (rawKey || '').trim().replace(/^["']|["']$/g, '').trim();
216
+ const projectId = config.projectId || 'my-rag-app';
217
+
218
+ try {
219
+ const payload = LicenseVerifier.verify(licenseKey, projectId);
220
+ return { ok: true, payload };
221
+ } catch (err: any) {
222
+ const message =
223
+ err instanceof Error
224
+ ? err.message
225
+ : 'Missing or invalid RETRIVORA_LICENSE_KEY. Set NEXT_PUBLIC_RETRIVORA_LICENSE_KEY in your environment or contact your administrator.';
226
+ const body = {
227
+ error: {
228
+ code: 'LICENSE_REVOKED',
229
+ message: 'Your Retrivora license key has been terminated, suspended, or revoked. Access denied.',
230
+ },
231
+ details: message,
232
+ };
233
+ return {
234
+ ok: false,
235
+ response: new Response(JSON.stringify(body), {
236
+ status: 403,
237
+ headers: { 'Content-Type': 'application/json' },
238
+ }),
239
+ };
240
+ }
241
+ }
242
+
112
243
  // ─── Input Sanitization ───────────────────────────────────────────────────────
113
244
 
114
245
  const MAX_MESSAGE_LENGTH = 8000;
@@ -205,8 +336,6 @@ function reportTelemetry(
205
336
  details,
206
337
  };
207
338
 
208
- console.log(`[Retrivora SDK Telemetry] Dispatching telemetry -> Feature: "${action}", Status: "${status}", Project: "${projectId}", Latency: ${latencyMs}ms`);
209
-
210
339
  fetch(absoluteUrl, {
211
340
  method: 'POST',
212
341
  headers: {
@@ -272,7 +401,29 @@ const SSE_HEADERS: Record<string, string> = {
272
401
  // ─── Handler Factories ────────────────────────────────────────────────────────
273
402
 
274
403
  /**
275
- * createChatHandler — factory that returns a Next.js App Router POST handler.
404
+ * Build a Next.js App Router POST handler for **synchronous** RAG chat.
405
+ *
406
+ * Pipeline (in execution order):
407
+ * 1. `options.onAuthorize` — custom authorization gate (optional)
408
+ * 2. In-memory IP rate limiter (30 req / 60 s sliding window)
409
+ * 3. JSON body parse → `enforceLicense()` RSA signature validation
410
+ * 4. Input sanitization + length / toxicity / PII / prompt-injection heuristics
411
+ * 5. User-message persistence via `DatabaseStorage`
412
+ * 6. LLM-driven RAG retrieval via `plugin.chat()`
413
+ * 7. Observability UI transformation + response persistence
414
+ * 8. JSON response (`ChatResponse` shape)
415
+ *
416
+ * Use this handler when you don't need streaming (e.g. server-side SDKs).
417
+ * For streaming use `createStreamHandler` — the same pipeline but with
418
+ * Server-Sent Events for incremental tokens.
419
+ *
420
+ * @param configOrPlugin - Either a partial `RagConfig` (a plugin will be
421
+ * created or reused for you) or an already-constructed
422
+ * `VectorPlugin` instance.
423
+ * @param options - See `HandlerOptions`. `onAuthorize` is recommended
424
+ * for multi-tenant deployments to gate access.
425
+ * @returns An async `POST(req, ctx)` function suitable for being the default
426
+ * export of a Next.js route file.
276
427
  */
277
428
  export function createChatHandler(
278
429
  configOrPlugin?: Partial<RagConfig> | VectorPlugin,
@@ -290,9 +441,38 @@ export function createChatHandler(
290
441
  const rateLimited = checkRateLimit(req);
291
442
  if (rateLimited) return rateLimited;
292
443
 
444
+ const config = plugin.getConfig();
445
+
293
446
  try {
294
447
  const body = await req.json();
295
448
  const bodyObj = (body ?? {}) as any;
449
+
450
+ // 2. License enforcement (after body parse so we can read body.licenseKey)
451
+ const licenseCheck = enforceLicense(req as NextRequest, config, bodyObj?.licenseKey);
452
+ if (!licenseCheck.ok) return licenseCheck.response as any;
453
+
454
+ // 2b. Free Tier limits check (hobby / free / free_trial tiers)
455
+ if (isFreeTier(licenseCheck.payload.tier)) {
456
+ const estimatedInputTokens = Math.ceil(
457
+ ((bodyObj.message?.length || 0) +
458
+ (Array.isArray(bodyObj.messages)
459
+ ? bodyObj.messages.reduce((a: number, m: any) => a + (m?.content?.length || m?.text?.length || 0), 0)
460
+ : 0) +
461
+ (bodyObj.history?.reduce((a: number, m: any) => a + (m?.content?.length || m?.text?.length || 0), 0) || 0)) / 4
462
+ );
463
+ const chatCheck = (FreeTierLimitsGuard as any).checkRequestAllowed({
464
+ operationType: 'chat',
465
+ inputTokens: estimatedInputTokens,
466
+ projectId: licenseCheck.payload.projectId,
467
+ });
468
+ if (!chatCheck.allowed) {
469
+ return createPaymentRequiredResponse(
470
+ chatCheck.reason || 'Free tier quota exceeded.',
471
+ { tier: licenseCheck.payload.tier, projectId: licenseCheck.payload.projectId }
472
+ );
473
+ }
474
+ }
475
+
296
476
  let rawMessage = bodyObj.message;
297
477
  if (!rawMessage && Array.isArray(bodyObj.messages) && bodyObj.messages.length > 0) {
298
478
  const lastMsg = bodyObj.messages[bodyObj.messages.length - 1];
@@ -304,7 +484,7 @@ export function createChatHandler(
304
484
  const history = bodyObj.history || (Array.isArray(bodyObj.messages) ? bodyObj.messages.slice(0, -1) : []);
305
485
  const { namespace, sessionId = 'default', messageId, userMessageId } = bodyObj;
306
486
 
307
- // 2. Input sanitization
487
+ // 3. Input sanitization
308
488
  const sanitized = sanitizeInput(rawMessage ?? '');
309
489
  if (!sanitized.ok) {
310
490
  return NextResponse.json({ error: { code: 'INVALID_INPUT', message: sanitized.error } }, { status: 400 });
@@ -348,7 +528,30 @@ export function createChatHandler(
348
528
  }
349
529
 
350
530
  /**
351
- * createStreamHandler — factory for a streaming SSE Next.js App Router POST handler.
531
+ * Build a Next.js App Router POST handler for **streaming** RAG chat via SSE.
532
+ *
533
+ * Same pipeline as `createChatHandler`, but the `plugin.chat()` call is run
534
+ * in streaming mode and every token is immediately emitted as a Server-Sent
535
+ * Event frame. The stream contains frames for `text`, `thinking`,
536
+ * `metadata`, `ui_transformation`, `observability`, and a terminal `done`.
537
+ *
538
+ * Multipart upload short-circuit: When the request `Content-Type` is
539
+ * `multipart/form-data` the request is transparently delegated to
540
+ * `createUploadHandler` (which also runs its own `enforceLicense()` gate).
541
+ *
542
+ * Pipeline (in execution order):
543
+ * 1. `options.onAuthorize` — custom authorization gate (optional)
544
+ * 2. In-memory IP rate limiter (30 req / 60 s sliding window)
545
+ * 3. JSON body parse (or form-data upload delegation)
546
+ * 4. `enforceLicense()` RSA signature validation — 403 if missing / terminated
547
+ * 5. Input sanitization + length / toxicity / PII / prompt-injection heuristics
548
+ * 6. User-message persistence via `DatabaseStorage`
549
+ * 7. Streaming RAG retrieval via `plugin.chat({ stream: true })`
550
+ * 8. Persist assistant message + emit SSE frames
551
+ *
552
+ * @param configOrPlugin - Partial `RagConfig` or an existing `VectorPlugin`.
553
+ * @param options - See `HandlerOptions`.
554
+ * @returns Async `POST(req, ctx)` function for `app/.../route.ts`.
352
555
  */
353
556
  export function createStreamHandler(
354
557
  configOrPlugin?: Partial<RagConfig> | VectorPlugin,
@@ -366,6 +569,8 @@ export function createStreamHandler(
366
569
  const rateLimited = checkRateLimit(req);
367
570
  if (rateLimited) return rateLimited;
368
571
 
572
+ const config = plugin.getConfig();
573
+
369
574
  let body: {
370
575
  message?: string;
371
576
  history?: ChatMessage[];
@@ -373,9 +578,11 @@ export function createStreamHandler(
373
578
  sessionId?: string;
374
579
  messageId?: string;
375
580
  userMessageId?: string;
581
+ licenseKey?: string;
376
582
  };
377
583
  try {
378
584
  if (req.headers.get('content-type')?.includes('multipart/form-data')) {
585
+ // Uploads bypass the JSON body parse — createUploadHandler enforces its own license check
379
586
  const uploader = createUploadHandler(plugin, { onAuthorize });
380
587
  return uploader(req);
381
588
  }
@@ -388,6 +595,33 @@ export function createStreamHandler(
388
595
  }
389
596
 
390
597
  const bodyObj = (body ?? {}) as any;
598
+
599
+ // 2b. License enforcement (for JSON-body requests)
600
+ const licenseCheck = enforceLicense(req as NextRequest, config, bodyObj?.licenseKey);
601
+ if (!licenseCheck.ok) return licenseCheck.response as any;
602
+
603
+ // 2c. Free Tier limits check (hobby / free / free_trial tiers)
604
+ if (isFreeTier(licenseCheck.payload.tier)) {
605
+ const estimatedInputTokens = Math.ceil(
606
+ ((bodyObj.message?.length || 0) +
607
+ (Array.isArray(bodyObj.messages)
608
+ ? bodyObj.messages.reduce((a: number, m: any) => a + (m?.content?.length || m?.text?.length || 0), 0)
609
+ : 0) +
610
+ (bodyObj.history?.reduce((a: number, m: any) => a + (m?.content?.length || m?.text?.length || 0), 0) || 0)) / 4
611
+ );
612
+ const chatCheck = (FreeTierLimitsGuard as any).checkRequestAllowed({
613
+ operationType: 'chat_stream',
614
+ inputTokens: estimatedInputTokens,
615
+ projectId: licenseCheck.payload.projectId,
616
+ });
617
+ if (!chatCheck.allowed) {
618
+ return createPaymentRequiredResponse(
619
+ chatCheck.reason || 'Free tier quota exceeded.',
620
+ { tier: licenseCheck.payload.tier, projectId: licenseCheck.payload.projectId }
621
+ );
622
+ }
623
+ }
624
+
391
625
  let rawMessage = bodyObj.message;
392
626
  if (!rawMessage && Array.isArray(bodyObj.messages) && bodyObj.messages.length > 0) {
393
627
  const lastMsg = bodyObj.messages[bodyObj.messages.length - 1];
@@ -399,7 +633,7 @@ export function createStreamHandler(
399
633
  const history = bodyObj.history || (Array.isArray(bodyObj.messages) ? bodyObj.messages.slice(0, -1) : []);
400
634
  const { namespace, sessionId = 'default', messageId, userMessageId } = bodyObj;
401
635
 
402
- // 2. Input sanitization
636
+ // 3. Input sanitization
403
637
  const sanitized = sanitizeInput(rawMessage ?? '');
404
638
  if (!sanitized.ok) {
405
639
  return new Response(
@@ -530,7 +764,19 @@ export function createStreamHandler(
530
764
  }
531
765
 
532
766
  /**
533
- * createIngestHandler factory for the document ingestion endpoint.
767
+ * Build a POST handler that ingests raw pre-parsed documents into the vector DB.
768
+ *
769
+ * Unlike `createUploadHandler` (which takes files and does parsing/splitting),
770
+ * this endpoint expects the caller to supply `documents[]` already split and
771
+ * prepared. This is useful for bulk backfill jobs, migrations, or programmatic
772
+ * sync pipelines.
773
+ *
774
+ * Security: Requires a valid license (RSA signature check via `enforceLicense`)
775
+ * AFTER JSON body parse so the optional `body.licenseKey` override is read.
776
+ *
777
+ * @param configOrPlugin - Partial `RagConfig` or existing `VectorPlugin`.
778
+ * @param options - Optional `onAuthorize` gate.
779
+ * @returns Async POST handler.
534
780
  */
535
781
  export function createIngestHandler(
536
782
  configOrPlugin?: Partial<RagConfig> | VectorPlugin,
@@ -543,8 +789,15 @@ export function createIngestHandler(
543
789
  const authResult = await checkAuth(req, onAuthorize);
544
790
  if (authResult) return authResult as any;
545
791
 
792
+ const config = plugin.getConfig();
793
+
546
794
  try {
547
795
  const body = await req.json();
796
+
797
+ // License enforcement before ingestion
798
+ const licenseCheck = enforceLicense(req as NextRequest, config, body?.licenseKey);
799
+ if (!licenseCheck.ok) return licenseCheck.response as any;
800
+
548
801
  const { documents, namespace } = body as {
549
802
  documents: Array<{ docId: string; content: string; metadata?: Record<string, unknown> }>;
550
803
  namespace?: string;
@@ -554,6 +807,29 @@ export function createIngestHandler(
554
807
  return NextResponse.json({ error: 'documents array is required' }, { status: 400 });
555
808
  }
556
809
 
810
+ // Free Tier ingestion limits check
811
+ if (isFreeTier(licenseCheck.payload.tier)) {
812
+ const totalIncomingBytes = documents.reduce(
813
+ (sum, d) => sum + Buffer.byteLength(d.content || '', 'utf8'),
814
+ 0
815
+ );
816
+ const ingestCheck = (FreeTierLimitsGuard as any).checkIngestionAllowed(
817
+ { documentCount: documents.length },
818
+ totalIncomingBytes
819
+ );
820
+ if (!ingestCheck.allowed) {
821
+ return createPaymentRequiredResponse(
822
+ ingestCheck.reason || 'Free tier ingestion limit reached.',
823
+ {
824
+ tier: licenseCheck.payload.tier,
825
+ projectId: licenseCheck.payload.projectId,
826
+ documents: documents.length,
827
+ incomingBytes: totalIncomingBytes,
828
+ }
829
+ );
830
+ }
831
+ }
832
+
557
833
  const results = await plugin.ingest(documents, namespace);
558
834
  reportTelemetry(req, plugin, 'DOCUMENT_INGESTION', 'success', `Ingested ${documents.length} document(s)`);
559
835
  return NextResponse.json({ results });
@@ -566,7 +842,28 @@ export function createIngestHandler(
566
842
  }
567
843
 
568
844
  /**
569
- * createHealthHandler factory for the health-check endpoint.
845
+ * Build a GET handler that returns health / readiness status.
846
+ *
847
+ * Response shape (200 OK):
848
+ * ```json
849
+ * {
850
+ * "ok": true,
851
+ * "status": "healthy",
852
+ * "sdkVersion": "x.y.z",
853
+ * "timestamp": "ISO string",
854
+ * "embedding": { "provider": "...", "ok": true },
855
+ * "vectorStore": { "provider": "...", "ok": true }
856
+ * }
857
+ * ```
858
+ *
859
+ * License check: This endpoint is intentionally **NOT** license-gated so
860
+ * orchestrators (Kubernetes, uptime probes, Vercel cron pings) can use it
861
+ * to assert the process is alive even when no license has been configured
862
+ * yet during deploy.
863
+ *
864
+ * @param configOrPlugin - Partial `RagConfig` or existing `VectorPlugin`.
865
+ * @param options - Optional `onAuthorize` gate (rarely used here).
866
+ * @returns Async GET handler.
570
867
  */
571
868
  export function createHealthHandler(
572
869
  configOrPlugin?: Partial<RagConfig> | VectorPlugin,
@@ -597,7 +894,19 @@ export function createHealthHandler(
597
894
  }
598
895
 
599
896
  /**
600
- * createLicenseHandler factory for the license validation endpoint (/v1/license/validate).
897
+ * Build a POST handler for the SDK-facing `/v1/license/validate` endpoint.
898
+ *
899
+ * Client flow: `LicenseValidator.validate()` calls this URL, receives
900
+ * authoritative DB-backed status (ACTIVE / TERMINATED / SUSPENDED / EXPIRED /
901
+ * REVOKED), and falls back to pure-local `LicenseVerifier.verify()` if the
902
+ * server is unreachable or returns an error.
903
+ *
904
+ * This handler does NOT enforce license — it is the verifier itself. It does,
905
+ * however, still apply rate limiting to mitigate abuse.
906
+ *
907
+ * @param configOrPlugin - Partial `RagConfig` or existing `VectorPlugin`.
908
+ * @param options - Optional `onAuthorize` gate.
909
+ * @returns Async POST handler returning `LicenseValidationResponse` JSON.
601
910
  */
602
911
  export function createLicenseHandler(
603
912
  configOrPlugin?: Partial<RagConfig> | VectorPlugin,
@@ -615,15 +924,17 @@ export function createLicenseHandler(
615
924
  try {
616
925
  const body = await req.json().catch(() => ({}));
617
926
  const config = plugin.getConfig();
618
- const licenseKey =
927
+ const rawKey =
619
928
  req.headers.get('x-license-key') ||
620
929
  body?.licenseKey ||
621
930
  req.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ||
622
931
  config.licenseKey ||
623
- process.env.RETRIVORA_LICENSE_KEY ||
624
932
  process.env.NEXT_PUBLIC_RETRIVORA_LICENSE_KEY ||
933
+ process.env.RETRIVORA_LICENSE_KEY ||
625
934
  '';
626
935
 
936
+ const licenseKey = (rawKey || '').trim().replace(/^["']|["']$/g, '').trim();
937
+
627
938
  const projectId = body?.projectId || config.projectId || 'my-rag-app';
628
939
 
629
940
  const payload = LicenseVerifier.verify(licenseKey, projectId);
@@ -655,7 +966,21 @@ export function createLicenseHandler(
655
966
  }
656
967
 
657
968
  /**
658
- * createUploadHandler factory for the file upload ingestion endpoint.
969
+ * Build a POST handler that accepts `multipart/form-data` file uploads.
970
+ *
971
+ * Workflow:
972
+ * 1. `onAuthorize` → license enforcement via `enforceLicense` (no JSON body,
973
+ * so the key must come from headers or the environment).
974
+ * 2. Read FormData files[] list → max 25 files per request, 100 MB each.
975
+ * 3. Per-file: use `DocumentParser` to extract raw text by MIME type (PDF,
976
+ * DOCX, TXT, MD, HTML, JSON, CSV, images via OCR fallback, etc.).
977
+ * 4. Chunk by the configured chunker (RecursiveCharacter, semantic, etc.).
978
+ * 5. Call `plugin.ingest()` to embed and insert into the vector store.
979
+ * 6. Return JSON summary per file (status, chunks ingested, docId).
980
+ *
981
+ * @param configOrPlugin - Partial `RagConfig` or existing `VectorPlugin`.
982
+ * @param options - Optional `onAuthorize` gate.
983
+ * @returns Async POST handler.
659
984
  */
660
985
  export function createUploadHandler(
661
986
  configOrPlugin?: Partial<RagConfig> | VectorPlugin,
@@ -668,6 +993,11 @@ export function createUploadHandler(
668
993
  const authResult = await checkAuth(req, onAuthorize);
669
994
  if (authResult) return authResult as any;
670
995
 
996
+ // License enforcement (uploads use headers/env for license key, no JSON body)
997
+ const config = plugin.getConfig();
998
+ const licenseCheck = enforceLicense(req as NextRequest, config);
999
+ if (!licenseCheck.ok) return licenseCheck.response as any;
1000
+
671
1001
  try {
672
1002
  const formData = await req.formData();
673
1003
  const files = formData.getAll('files') as File[];
@@ -679,6 +1009,28 @@ export function createUploadHandler(
679
1009
  return NextResponse.json({ error: 'No files provided' }, { status: 400 });
680
1010
  }
681
1011
 
1012
+ // Free Tier upload limits check (before parsing to fail fast on large uploads)
1013
+ if (isFreeTier(licenseCheck.payload.tier)) {
1014
+ const totalUploadBytes = files.reduce((sum, f) => sum + (f.size || 0), 0);
1015
+ const uploadCheck = (FreeTierLimitsGuard as any).checkIngestionAllowed(
1016
+ { documentCount: files.length },
1017
+ totalUploadBytes
1018
+ );
1019
+ if (!uploadCheck.allowed) {
1020
+ return createPaymentRequiredResponse(
1021
+ uploadCheck.reason || 'Free tier upload limit reached.',
1022
+ {
1023
+ tier: licenseCheck.payload.tier,
1024
+ projectId: licenseCheck.payload.projectId,
1025
+ fileCount: files.length,
1026
+ totalUploadBytes,
1027
+ maxFileSizeBytes: FREE_TIER_QUOTAS.MAX_FILE_SIZE_BYTES,
1028
+ maxStorageBytes: FREE_TIER_QUOTAS.MAX_STORAGE_BYTES,
1029
+ }
1030
+ );
1031
+ }
1032
+ }
1033
+
682
1034
  const documents: { docId: string; content: string; metadata?: Record<string, unknown> }[] = [];
683
1035
 
684
1036
  for (const file of files) {
@@ -803,8 +1155,21 @@ export function createUploadHandler(
803
1155
  }
804
1156
 
805
1157
  /**
806
- * createSuggestionsHandler factory for the auto-suggestions endpoint.
807
- * Supports both POST (JSON body) and GET (URL search parameters).
1158
+ * Build a GET/POST handler for auto-suggestions (used by ChatWidget prompt chips).
1159
+ *
1160
+ * Requests:
1161
+ * - GET `?query=...&namespace=...`
1162
+ * - POST `{ query: string, namespace?: string, licenseKey?: string }`
1163
+ *
1164
+ * Response (200 OK): `{ suggestions: string[] }`
1165
+ *
1166
+ * License enforcement: Runs BEFORE body parse on headers/env, and if the POST
1167
+ * body contains a `licenseKey` override the check is repeated with the body
1168
+ * key included so the per-request override is honored.
1169
+ *
1170
+ * @param configOrPlugin - Partial `RagConfig` or existing `VectorPlugin`.
1171
+ * @param options - Optional `onAuthorize` gate.
1172
+ * @returns Async handler accepting both GET and POST methods.
808
1173
  */
809
1174
  export function createSuggestionsHandler(
810
1175
  configOrPlugin?: Partial<RagConfig> | VectorPlugin,
@@ -817,14 +1182,26 @@ export function createSuggestionsHandler(
817
1182
  const authResult = await checkAuth(req, onAuthorize);
818
1183
  if (authResult) return authResult as any;
819
1184
 
1185
+ // License enforcement for auto-suggestions endpoint
1186
+ const config = plugin.getConfig();
1187
+ const licenseCheck = enforceLicense(req, config);
1188
+ if (!licenseCheck.ok) return licenseCheck.response as any;
1189
+
820
1190
  try {
821
1191
  let query = '';
822
1192
  let namespace: string | undefined = undefined;
1193
+ let bodyLicenseKey: string | undefined = undefined;
823
1194
 
824
1195
  if (req.method === 'POST') {
825
1196
  const body = await req.json().catch(() => ({}));
826
1197
  query = body.query || '';
827
1198
  namespace = body.namespace;
1199
+ bodyLicenseKey = body.licenseKey;
1200
+ // Re-check license if body contained a key override
1201
+ if (bodyLicenseKey) {
1202
+ const recheck = enforceLicense(req, config, bodyLicenseKey);
1203
+ if (!recheck.ok) return recheck.response as any;
1204
+ }
828
1205
  } else {
829
1206
  const url = new URL(req.url);
830
1207
  query = url.searchParams.get('query') || '';
@@ -847,7 +1224,19 @@ export function createSuggestionsHandler(
847
1224
  }
848
1225
 
849
1226
  /**
850
- * createHistoryHandler factory for GET /api/retrivora/history and POST /api/retrivora/history/clear.
1227
+ * Build a handler for chat-history listing and clearing.
1228
+ *
1229
+ * Routes dispatched by `createRagHandler`:
1230
+ * - GET `/history?sessionId=...&limit=...` → returns `{ messages: HistoryMessage[] }`
1231
+ * - POST `/history/clear` with `{ sessionId? }` JSON body → returns `{ ok: true }`
1232
+ *
1233
+ * Storage: Uses `DatabaseStorage` (default SQLite or custom configured driver).
1234
+ * License enforcement is handled by the upstream `createRagHandler` router when
1235
+ * this handler is composed through it; standalone usage is rare.
1236
+ *
1237
+ * @param configOrPlugin - Partial `RagConfig` or existing `VectorPlugin`.
1238
+ * @param options - Optional `onAuthorize` gate.
1239
+ * @returns Async handler accepting GET (list) or POST (clear) methods.
851
1240
  */
852
1241
  export function createHistoryHandler(
853
1242
  configOrPlugin?: Partial<RagConfig> | VectorPlugin,
@@ -856,17 +1245,20 @@ export function createHistoryHandler(
856
1245
  const plugin = getOrCreatePlugin(configOrPlugin);
857
1246
  const storage = new DatabaseStorage(plugin.getConfig());
858
1247
  const onAuthorize = options?.onAuthorize;
1248
+ const onResolveScope = options?.onResolveScope;
859
1249
 
860
1250
  return async function handler(req: NextRequest) {
861
1251
  const authResult = await checkAuth(req, onAuthorize);
862
1252
  if (authResult) return authResult as any;
863
1253
 
1254
+ const scope = onResolveScope ? await onResolveScope(req) : undefined;
1255
+
864
1256
  const url = new URL(req.url);
865
1257
  const sessionId = url.searchParams.get('sessionId') || 'default';
866
1258
 
867
1259
  if (req.method === 'GET') {
868
1260
  try {
869
- const history = await storage.getHistory(sessionId);
1261
+ const history = await storage.getHistory(sessionId, scope);
870
1262
  return NextResponse.json({ history });
871
1263
  } catch (err) {
872
1264
  const message = err instanceof Error ? err.message : 'Failed to fetch history';
@@ -876,7 +1268,7 @@ export function createHistoryHandler(
876
1268
  try {
877
1269
  const body = await req.json().catch(() => ({}));
878
1270
  const sid = body.sessionId || sessionId;
879
- await storage.clearHistory(sid);
1271
+ await storage.clearHistory(sid, scope);
880
1272
  return NextResponse.json({ success: true });
881
1273
  } catch (err) {
882
1274
  const message = err instanceof Error ? err.message : 'Failed to clear history';
@@ -888,7 +1280,17 @@ export function createHistoryHandler(
888
1280
  }
889
1281
 
890
1282
  /**
891
- * createFeedbackHandler factory for POST /api/retrivora/feedback.
1283
+ * Build a POST handler for collecting thumbs-up / thumbs-down user feedback.
1284
+ *
1285
+ * Request: `{ messageId, sessionId?, rating: 1 | -1, comment?, reason? }`
1286
+ * Response: `{ ok: true, id: string }` (201 Created)
1287
+ *
1288
+ * Feedback is written to `DatabaseStorage.feedback` for RAG pipeline
1289
+ * evaluation, offline fine-tuning dataset curation, and support triage.
1290
+ *
1291
+ * @param configOrPlugin - Partial `RagConfig` or existing `VectorPlugin`.
1292
+ * @param options - Optional `onAuthorize` gate.
1293
+ * @returns Async POST handler.
892
1294
  */
893
1295
  export function createFeedbackHandler(
894
1296
  configOrPlugin?: Partial<RagConfig> | VectorPlugin,
@@ -897,20 +1299,23 @@ export function createFeedbackHandler(
897
1299
  const plugin = getOrCreatePlugin(configOrPlugin);
898
1300
  const storage = new DatabaseStorage(plugin.getConfig());
899
1301
  const onAuthorize = options?.onAuthorize;
1302
+ const onResolveScope = options?.onResolveScope;
900
1303
 
901
1304
  return async function handler(req: NextRequest) {
902
1305
  const authResult = await checkAuth(req, onAuthorize);
903
1306
  if (authResult) return authResult as any;
904
1307
 
1308
+ const scope = onResolveScope ? await onResolveScope(req) : undefined;
1309
+
905
1310
  if (req.method === 'GET') {
906
1311
  try {
907
1312
  const url = new URL(req.url);
908
1313
  const messageId = url.searchParams.get('messageId');
909
1314
  if (!messageId) {
910
- const feedbackList = await storage.listFeedback();
1315
+ const feedbackList = await storage.listFeedback(scope);
911
1316
  return NextResponse.json({ feedback: feedbackList });
912
1317
  }
913
- const feedback = await storage.getFeedback(messageId);
1318
+ const feedback = await storage.getFeedback(messageId, scope);
914
1319
  return NextResponse.json({ feedback });
915
1320
  } catch (err) {
916
1321
  const message = err instanceof Error ? err.message : 'Failed to fetch feedback';
@@ -932,6 +1337,17 @@ export function createFeedbackHandler(
932
1337
  if (!rating) {
933
1338
  return NextResponse.json({ error: 'rating is required' }, { status: 400 });
934
1339
  }
1340
+ if (scope && scope.length > 0) {
1341
+ const matches = scope.some(
1342
+ (pid) => pid && (sessionId === pid || sessionId.startsWith(pid) || sessionId.includes(pid))
1343
+ );
1344
+ if (!matches) {
1345
+ return NextResponse.json(
1346
+ { error: 'sessionId is outside the authorized project scope' },
1347
+ { status: 403 }
1348
+ );
1349
+ }
1350
+ }
935
1351
 
936
1352
  await storage.saveFeedback({ messageId, sessionId, rating, comment });
937
1353
  return NextResponse.json({ success: true });
@@ -945,7 +1361,17 @@ export function createFeedbackHandler(
945
1361
  }
946
1362
 
947
1363
  /**
948
- * createSessionsHandler factory for GET /api/retrivora/sessions.
1364
+ * Build a GET handler that lists the set of chat sessions.
1365
+ *
1366
+ * Response: `{ sessions: { id, title, lastMessageAt, preview }[] }`
1367
+ *
1368
+ * Sessions are read from `DatabaseStorage`. Combined with `createHistoryHandler`
1369
+ * this enables a sidebar of prior conversations for custom UIs built on top of
1370
+ * the `useRagChat` headless hook.
1371
+ *
1372
+ * @param configOrPlugin - Partial `RagConfig` or existing `VectorPlugin`.
1373
+ * @param options - Optional `onAuthorize` gate.
1374
+ * @returns Async GET handler returning a session listing.
949
1375
  */
950
1376
  export function createSessionsHandler(
951
1377
  configOrPlugin?: Partial<RagConfig> | VectorPlugin,
@@ -954,15 +1380,16 @@ export function createSessionsHandler(
954
1380
  const plugin = getOrCreatePlugin(configOrPlugin);
955
1381
  const storage = new DatabaseStorage(plugin.getConfig());
956
1382
  const onAuthorize = options?.onAuthorize;
1383
+ const onResolveScope = options?.onResolveScope;
957
1384
 
958
1385
  return async function GET(req?: NextRequest) {
959
1386
  if (req) {
960
1387
  const authResult = await checkAuth(req, onAuthorize);
961
1388
  if (authResult) return authResult as any;
962
1389
  }
963
- void req;
964
1390
  try {
965
- const sessions = await storage.listSessions();
1391
+ const scope = req && onResolveScope ? await onResolveScope(req) : undefined;
1392
+ const sessions = await storage.listSessions(scope);
966
1393
  return NextResponse.json({ sessions });
967
1394
  } catch (err) {
968
1395
  const message = err instanceof Error ? err.message : 'Failed to list sessions';
@@ -972,8 +1399,36 @@ export function createSessionsHandler(
972
1399
  }
973
1400
 
974
1401
  /**
975
- * createRagHandler factory that returns GET and POST handlers for a catch-all route
976
- * (e.g. `src/app/api/retrivora/[[...retrivora]]/route.ts`).
1402
+ * Build a Next.js catch-all route that dispatches to every sub-handler by path.
1403
+ *
1404
+ * Intended drop-in export:
1405
+ * ```ts
1406
+ * // app/api/retrivora/[[...retrivora]]/route.ts
1407
+ * import { createRagHandler } from '@retrivora-ai/rag-engine';
1408
+ * const { GET, POST } = createRagHandler({ /* RagConfig *\/ });
1409
+ * export { GET, POST };
1410
+ * ```
1411
+ *
1412
+ * Segments dispatched (POST unless marked GET):
1413
+ * - `chat` → createStreamHandler (SSE streaming chat)
1414
+ * - `upload` → createUploadHandler (file ingestion)
1415
+ * - `suggestions` → createSuggestionsHandler (GET/POST)
1416
+ * - `v1/license/validate` → createLicenseHandler
1417
+ * - `health` → createHealthHandler (GET)
1418
+ * - `feedback` → createFeedbackHandler
1419
+ * - `history` → createHistoryHandler (GET/POST)
1420
+ * - `sessions` → createSessionsHandler (GET)
1421
+ * - Any other segment → 404 with a JSON error body
1422
+ *
1423
+ * This is the **recommended** way to mount the SDK in a Next.js app. Using
1424
+ * any single handler individually without its license enforcement gate is a
1425
+ * security anti-pattern — prefer `createRagHandler` unless you have a very
1426
+ * specific reason to split routes up.
1427
+ *
1428
+ * @param configOrPlugin - Partial `RagConfig` or existing `VectorPlugin`.
1429
+ * @param options - Optional `onAuthorize` gate (propagated to every
1430
+ * sub-handler so auth rules apply uniformly).
1431
+ * @returns An object `{ GET, POST }` to re-export as your route file defaults.
977
1432
  */
978
1433
  export function createRagHandler(
979
1434
  configOrPlugin?: Partial<RagConfig> | VectorPlugin,
@@ -981,16 +1436,18 @@ export function createRagHandler(
981
1436
  ) {
982
1437
  const plugin = getOrCreatePlugin(configOrPlugin);
983
1438
  const onAuthorize = options?.onAuthorize;
984
-
985
- const chatHandler = createChatHandler(plugin, { onAuthorize });
986
- const streamHandler = createStreamHandler(plugin, { onAuthorize });
987
- const uploadHandler = createUploadHandler(plugin, { onAuthorize });
988
- const healthHandler = createHealthHandler(plugin, { onAuthorize });
989
- const suggestionsHandler = createSuggestionsHandler(plugin, { onAuthorize });
990
- const historyHandler = createHistoryHandler(plugin, { onAuthorize });
991
- const feedbackHandler = createFeedbackHandler(plugin, { onAuthorize });
992
- const sessionsHandler = createSessionsHandler(plugin, { onAuthorize });
993
- const licenseHandler = createLicenseHandler(plugin, { onAuthorize });
1439
+ const onResolveScope = options?.onResolveScope;
1440
+ const scopeableOptions: HandlerOptions = { onAuthorize, onResolveScope };
1441
+
1442
+ const chatHandler = createChatHandler(plugin, scopeableOptions);
1443
+ const streamHandler = createStreamHandler(plugin, scopeableOptions);
1444
+ const uploadHandler = createUploadHandler(plugin, scopeableOptions);
1445
+ const healthHandler = createHealthHandler(plugin, scopeableOptions);
1446
+ const suggestionsHandler = createSuggestionsHandler(plugin, scopeableOptions);
1447
+ const historyHandler = createHistoryHandler(plugin, scopeableOptions);
1448
+ const feedbackHandler = createFeedbackHandler(plugin, scopeableOptions);
1449
+ const sessionsHandler = createSessionsHandler(plugin, scopeableOptions);
1450
+ const licenseHandler = createLicenseHandler(plugin, scopeableOptions);
994
1451
 
995
1452
  async function routePostRequest(req: any, segment: string) {
996
1453
  switch (segment) {