@retrivora-ai/rag-engine 2.3.0 → 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.
@@ -291,8 +291,11 @@ export class DatabaseStorage {
291
291
  }
292
292
  }
293
293
 
294
- async getHistory(sessionId: string): Promise<HistoryMessage[]> {
294
+ async getHistory(sessionId: string, projectIds?: string[]): Promise<HistoryMessage[]> {
295
295
  this.checkHistoryEnabled();
296
+ if (projectIds && projectIds.length > 0 && !this.sessionInScope(sessionId, projectIds)) {
297
+ return [];
298
+ }
296
299
  if (this.provider === 'postgresql' || this.provider === 'pgvector') {
297
300
  const pool = await this.getPGPool();
298
301
  const res = await pool.query(
@@ -328,8 +331,11 @@ export class DatabaseStorage {
328
331
  }
329
332
  }
330
333
 
331
- async clearHistory(sessionId: string): Promise<void> {
334
+ async clearHistory(sessionId: string, projectIds?: string[]): Promise<void> {
332
335
  this.checkHistoryEnabled();
336
+ if (projectIds && projectIds.length > 0 && !this.sessionInScope(sessionId, projectIds)) {
337
+ return;
338
+ }
333
339
  if (this.provider === 'postgresql' || this.provider === 'pgvector') {
334
340
  const pool = await this.getPGPool();
335
341
  await pool.query(`DELETE FROM ${this.historyTableName} WHERE session_id = $1`, [sessionId]);
@@ -350,21 +356,37 @@ export class DatabaseStorage {
350
356
  }
351
357
  }
352
358
 
353
- async listSessions(): Promise<string[]> {
359
+ /**
360
+ * Return true when `sessionId` falls into the allowed `projectIds` scope.
361
+ * When `projectIds` is empty / undefined we allow the call (legacy behavior
362
+ * for non-multi-tenant consumers). Session ids typically begin with the
363
+ * project id they were created for, so we prefix-match as well as contains-match
364
+ * to catch sharded / suffix-style ids used by some integrations.
365
+ */
366
+ private sessionInScope(sessionId: string, projectIds?: string[]): boolean {
367
+ if (!projectIds || projectIds.length === 0) return true;
368
+ return projectIds.some(
369
+ (pid) => pid && (sessionId === pid || sessionId.startsWith(pid) || sessionId.includes(pid))
370
+ );
371
+ }
372
+
373
+ async listSessions(projectIds?: string[]): Promise<string[]> {
354
374
  this.checkHistoryEnabled();
375
+ let raw: string[] = [];
355
376
  if (this.provider === 'postgresql' || this.provider === 'pgvector') {
356
377
  const pool = await this.getPGPool();
357
378
  const res = await pool.query(`SELECT DISTINCT session_id FROM ${this.historyTableName}`);
358
- return res.rows.map((r: any) => r.session_id);
379
+ raw = res.rows.map((r: any) => r.session_id);
359
380
  } else if (this.provider === 'mongodb') {
360
381
  await this.getMongoClient();
361
382
  const db = this.getMongoDb();
362
- return db.collection(this.historyTableName).distinct('session_id');
383
+ raw = await db.collection(this.historyTableName).distinct('session_id');
363
384
  } else {
364
385
  const history = this.readLocalFile(this.historyFile);
365
- const sessions = new Set<string>(history.map((h) => h.session_id));
366
- return Array.from(sessions);
386
+ raw = Array.from(new Set<string>(history.map((h) => h.session_id)));
367
387
  }
388
+ if (!projectIds || projectIds.length === 0) return raw;
389
+ return raw.filter((sid) => this.sessionInScope(sid, projectIds));
368
390
  }
369
391
 
370
392
  // ─── Feedback Operations ──────────────────────────────────────────────────
@@ -422,8 +444,9 @@ export class DatabaseStorage {
422
444
  }
423
445
  }
424
446
 
425
- async getFeedback(messageId: string): Promise<FeedbackItem | null> {
447
+ async getFeedback(messageId: string, projectIds?: string[]): Promise<FeedbackItem | null> {
426
448
  this.checkFeedbackEnabled();
449
+ let item: FeedbackItem | null = null;
427
450
  if (this.provider === 'postgresql' || this.provider === 'pgvector') {
428
451
  const pool = await this.getPGPool();
429
452
  const res = await pool.query(
@@ -432,29 +455,35 @@ export class DatabaseStorage {
432
455
  WHERE message_id = $1`,
433
456
  [messageId]
434
457
  );
435
- return res.rows[0] || null;
458
+ item = res.rows[0] || null;
436
459
  } else if (this.provider === 'mongodb') {
437
460
  await this.getMongoClient();
438
461
  const db = this.getMongoDb();
439
462
  const col = db.collection(this.feedbackTableName);
440
- const item = await col.findOne({ message_id: messageId });
441
- if (!item) return null;
442
- return {
443
- id: item.id,
444
- messageId: item.message_id,
445
- sessionId: item.session_id,
446
- rating: item.rating,
447
- comment: item.comment,
448
- createdAt: item.created_at,
449
- };
463
+ const raw = await col.findOne({ message_id: messageId });
464
+ if (raw) {
465
+ item = {
466
+ id: raw.id,
467
+ messageId: raw.message_id,
468
+ sessionId: raw.session_id,
469
+ rating: raw.rating,
470
+ comment: raw.comment,
471
+ createdAt: raw.created_at,
472
+ };
473
+ }
450
474
  } else {
451
475
  const list = this.readLocalFile(this.feedbackFile);
452
- return list.find((f) => f.message_id === messageId) || null;
476
+ item = list.find((f) => f.message_id === messageId) || null;
477
+ }
478
+ if (item && projectIds && projectIds.length > 0) {
479
+ return this.sessionInScope(item.sessionId, projectIds) ? item : null;
453
480
  }
481
+ return item;
454
482
  }
455
483
 
456
- async listFeedback(): Promise<FeedbackItem[]> {
484
+ async listFeedback(projectIds?: string[]): Promise<FeedbackItem[]> {
457
485
  this.checkFeedbackEnabled();
486
+ let raw: FeedbackItem[] = [];
458
487
  if (this.provider === 'postgresql' || this.provider === 'pgvector') {
459
488
  const pool = await this.getPGPool();
460
489
  const res = await pool.query(
@@ -462,13 +491,13 @@ export class DatabaseStorage {
462
491
  FROM ${this.feedbackTableName}
463
492
  ORDER BY created_at DESC`
464
493
  );
465
- return res.rows;
494
+ raw = res.rows;
466
495
  } else if (this.provider === 'mongodb') {
467
496
  await this.getMongoClient();
468
497
  const db = this.getMongoDb();
469
498
  const col = db.collection(this.feedbackTableName);
470
499
  const items = await col.find({}).sort({ created_at: -1 }).toArray();
471
- return items.map((item: any) => ({
500
+ raw = items.map((item: any) => ({
472
501
  id: item.id,
473
502
  messageId: item.message_id,
474
503
  sessionId: item.session_id,
@@ -478,8 +507,10 @@ export class DatabaseStorage {
478
507
  }));
479
508
  } else {
480
509
  const list = this.readLocalFile(this.feedbackFile);
481
- return [...list].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
510
+ raw = [...list].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
482
511
  }
512
+ if (!projectIds || projectIds.length === 0) return raw;
513
+ return raw.filter((f) => this.sessionInScope(f.sessionId, projectIds));
483
514
  }
484
515
 
485
516
  async disconnect() {
@@ -0,0 +1,281 @@
1
+ /**
2
+ * FreeTierLimitsGuard — Enforces strict operational quotas for Free Tier workspaces.
3
+ * Retrivora Free Tier (Hobby) policy enforcement & Request Units accounting.
4
+ *
5
+ * Inline copy for the published SDK package (@retrivora-ai/rag-engine). The
6
+ * canonical source lives in `services/billing/FreeTierLimitsGuard.ts` and is
7
+ * kept in sync for the monorepo via copy on changes; bundling it here ensures
8
+ * published dist builds don't depend on `@services/*` path aliases that only
9
+ * resolve inside the Turborepo workspace.
10
+ */
11
+
12
+ export interface WorkspaceUsageStats {
13
+ documentCount: number;
14
+ totalStorageBytes: number;
15
+ totalEmbeddingsCount: number;
16
+ dailyQueriesCount: number;
17
+ totalRequestUnitsUsed?: number;
18
+ dailyRequestUnitsUsed?: number;
19
+ trialStartedAt?: string;
20
+ concurrentRequests?: number;
21
+ }
22
+
23
+ export const FREE_TIER_QUOTAS = {
24
+ MAX_DOCUMENTS: 25,
25
+ MAX_FILE_SIZE_BYTES: 20 * 1024 * 1024,
26
+ MAX_STORAGE_BYTES: 250 * 1024 * 1024,
27
+ MAX_TRIAL_REQUEST_UNITS: 500,
28
+ MAX_DAILY_REQUEST_UNITS: 50,
29
+ TRIAL_DURATION_DAYS: 14,
30
+ GRACE_PERIOD_DAYS: 2,
31
+ MAX_RPM: 10,
32
+ MAX_RPH: 100,
33
+ MAX_RPD: 500,
34
+ MAX_CONCURRENT_REQUESTS: 2,
35
+ MAX_INPUT_TOKENS: 20000,
36
+ MAX_OUTPUT_TOKENS: 4000,
37
+ UPGRADE_REMINDER_DAYS: [7, 12, 14] as const,
38
+ MAX_USERS: 1,
39
+ MAX_NAMESPACES: 1,
40
+ MAX_PROJECTS: 1,
41
+ } as const;
42
+
43
+ export interface TrialTimestamps {
44
+ trial_started_at: string;
45
+ trial_expires_at: string;
46
+ grace_period_expires_at: string;
47
+ }
48
+
49
+ export interface TrialStatusResult {
50
+ status: 'active' | 'grace_period' | 'expired';
51
+ daysElapsed: number;
52
+ daysRemaining: number;
53
+ isGracePeriod: boolean;
54
+ isExpired: boolean;
55
+ upgradeReminderDue?: number;
56
+ usageReminderDue?: '80%' | '90%' | '100%';
57
+ reason?: string;
58
+ }
59
+
60
+ export interface RequestCheckParams {
61
+ operationType?: string;
62
+ inputTokens?: number;
63
+ outputTokens?: number;
64
+ dailyUnitsUsed?: number;
65
+ totalUnitsUsed?: number;
66
+ trialStartedAt?: string | Date;
67
+ concurrentRequests?: number;
68
+ chunkCount?: number;
69
+ nowServerTime?: Date;
70
+ }
71
+
72
+ export class FreeTierLimitsGuard {
73
+ static calculateTrialTimestamps(startedAt?: Date | string): TrialTimestamps {
74
+ const startDate = startedAt ? new Date(startedAt) : new Date();
75
+ const trialStartedAtMs = startDate.getTime();
76
+
77
+ const trialExpiresAtMs = trialStartedAtMs + FREE_TIER_QUOTAS.TRIAL_DURATION_DAYS * 24 * 60 * 60 * 1000;
78
+ const gracePeriodExpiresAtMs =
79
+ trialStartedAtMs + (FREE_TIER_QUOTAS.TRIAL_DURATION_DAYS + FREE_TIER_QUOTAS.GRACE_PERIOD_DAYS) * 24 * 60 * 60 * 1000;
80
+
81
+ return {
82
+ trial_started_at: new Date(trialStartedAtMs).toISOString(),
83
+ trial_expires_at: new Date(trialExpiresAtMs).toISOString(),
84
+ grace_period_expires_at: new Date(gracePeriodExpiresAtMs).toISOString(),
85
+ };
86
+ }
87
+
88
+ static calculateRequestUnits(operationType: string, options?: { chunkCount?: number }): number {
89
+ const op = operationType.toLowerCase().trim();
90
+
91
+ if (
92
+ op.includes('suggestion') ||
93
+ op.includes('template') ||
94
+ op.includes('history') ||
95
+ op.includes('model') ||
96
+ op.includes('health') ||
97
+ op.includes('auth') ||
98
+ op.includes('license') ||
99
+ op.includes('usage') ||
100
+ op.includes('feedback') ||
101
+ op.includes('analytic')
102
+ ) {
103
+ return 0;
104
+ }
105
+
106
+ if (op.includes('image')) {
107
+ return 10;
108
+ }
109
+
110
+ if (op.includes('embedding')) {
111
+ const chunks = options?.chunkCount ?? 1;
112
+ return Math.max(1, Math.ceil(chunks / 1000));
113
+ }
114
+
115
+ return 1;
116
+ }
117
+
118
+ static checkTrialStatus(
119
+ startedAt?: Date | string,
120
+ totalUnitsUsed = 0,
121
+ nowServerTime = new Date()
122
+ ): TrialStatusResult {
123
+ const timestamps = this.calculateTrialTimestamps(startedAt);
124
+ const startMs = new Date(timestamps.trial_started_at).getTime();
125
+ const expireMs = new Date(timestamps.trial_expires_at).getTime();
126
+ const graceExpireMs = new Date(timestamps.grace_period_expires_at).getTime();
127
+ const nowMs = nowServerTime.getTime();
128
+
129
+ const daysElapsed = Math.floor(Math.max(0, nowMs - startMs) / (1000 * 60 * 60 * 24));
130
+ const daysRemaining = Math.max(0, Math.ceil((expireMs - nowMs) / (1000 * 60 * 60 * 24)));
131
+
132
+ const isConsumedUnits = totalUnitsUsed >= FREE_TIER_QUOTAS.MAX_TRIAL_REQUEST_UNITS;
133
+ const isExpiredTime = nowMs > expireMs;
134
+ const isPastGraceTime = nowMs > graceExpireMs;
135
+
136
+ let status: 'active' | 'grace_period' | 'expired' = 'active';
137
+ let reason: string | undefined;
138
+
139
+ if (isPastGraceTime || (isConsumedUnits && isExpiredTime)) {
140
+ status = 'expired';
141
+ reason = 'Trial period and grace period have expired. Upgrade your plan.';
142
+ } else if (isExpiredTime || isConsumedUnits) {
143
+ status = 'grace_period';
144
+ reason = isConsumedUnits
145
+ ? 'Total trial request quota (500 units) consumed. Grace period active (dashboard access only).'
146
+ : '14-day trial period expired. 2-day grace period active (dashboard access only).';
147
+ }
148
+
149
+ let upgradeReminderDue: number | undefined;
150
+ if (FREE_TIER_QUOTAS.UPGRADE_REMINDER_DAYS.includes(daysElapsed as any)) {
151
+ upgradeReminderDue = daysElapsed;
152
+ }
153
+
154
+ let usageReminderDue: '80%' | '90%' | '100%' | undefined;
155
+ const usagePercent = (totalUnitsUsed / FREE_TIER_QUOTAS.MAX_TRIAL_REQUEST_UNITS) * 100;
156
+ if (usagePercent >= 100) {
157
+ usageReminderDue = '100%';
158
+ } else if (usagePercent >= 90) {
159
+ usageReminderDue = '90%';
160
+ } else if (usagePercent >= 80) {
161
+ usageReminderDue = '80%';
162
+ }
163
+
164
+ return {
165
+ status,
166
+ daysElapsed,
167
+ daysRemaining,
168
+ isGracePeriod: status === 'grace_period',
169
+ isExpired: status === 'expired',
170
+ upgradeReminderDue,
171
+ usageReminderDue,
172
+ reason,
173
+ };
174
+ }
175
+
176
+ static checkIngestionAllowed(
177
+ stats: Partial<WorkspaceUsageStats>,
178
+ incomingSizeBytes = 0
179
+ ): { allowed: boolean; reason?: string } {
180
+ const docs = stats.documentCount ?? 0;
181
+ const storage = stats.totalStorageBytes ?? 0;
182
+
183
+ if (incomingSizeBytes > FREE_TIER_QUOTAS.MAX_FILE_SIZE_BYTES) {
184
+ const mbSize = (incomingSizeBytes / (1024 * 1024)).toFixed(1);
185
+ return {
186
+ allowed: false,
187
+ reason: `[FreeTierIngestor] File size (${mbSize}MB) exceeds maximum allowed size of 20MB under Free Tier rules.`,
188
+ };
189
+ }
190
+
191
+ if (docs >= FREE_TIER_QUOTAS.MAX_DOCUMENTS) {
192
+ return {
193
+ allowed: false,
194
+ reason: `Document limit reached (${docs}/${FREE_TIER_QUOTAS.MAX_DOCUMENTS}). Upgrade to Pro to ingest more documents.`,
195
+ };
196
+ }
197
+
198
+ if (storage + incomingSizeBytes > FREE_TIER_QUOTAS.MAX_STORAGE_BYTES) {
199
+ const mbUsed = (storage / (1024 * 1024)).toFixed(1);
200
+ return {
201
+ allowed: false,
202
+ reason: `Storage quota exceeded (${mbUsed}MB / 250MB). Upgrade for unlimited storage.`,
203
+ };
204
+ }
205
+
206
+ return { allowed: true };
207
+ }
208
+
209
+ static checkRequestAllowed(params: RequestCheckParams): { allowed: boolean; reason?: string; costUnits?: number } {
210
+ const op = params.operationType ?? 'chat';
211
+ const costUnits = this.calculateRequestUnits(op, { chunkCount: params.chunkCount });
212
+
213
+ if (costUnits === 0) {
214
+ return { allowed: true, costUnits: 0 };
215
+ }
216
+
217
+ if (params.inputTokens && params.inputTokens > FREE_TIER_QUOTAS.MAX_INPUT_TOKENS) {
218
+ return {
219
+ allowed: false,
220
+ reason: 'Token limit exceeded. Upgrade your plan.',
221
+ costUnits,
222
+ };
223
+ }
224
+ if (params.outputTokens && params.outputTokens > FREE_TIER_QUOTAS.MAX_OUTPUT_TOKENS) {
225
+ return {
226
+ allowed: false,
227
+ reason: 'Token limit exceeded. Upgrade your plan.',
228
+ costUnits,
229
+ };
230
+ }
231
+
232
+ if (params.concurrentRequests && params.concurrentRequests > FREE_TIER_QUOTAS.MAX_CONCURRENT_REQUESTS) {
233
+ return {
234
+ allowed: false,
235
+ reason: `Concurrent limit exceeded (max ${FREE_TIER_QUOTAS.MAX_CONCURRENT_REQUESTS} active requests). Please wait for active streams to finish.`,
236
+ costUnits,
237
+ };
238
+ }
239
+
240
+ const totalUnits = params.totalUnitsUsed ?? 0;
241
+ const trialStatus = this.checkTrialStatus(params.trialStartedAt, totalUnits, params.nowServerTime);
242
+
243
+ if (trialStatus.status === 'expired' || trialStatus.status === 'grace_period') {
244
+ return {
245
+ allowed: false,
246
+ reason: trialStatus.reason || 'Trial limit reached. Upgrade your plan.',
247
+ costUnits,
248
+ };
249
+ }
250
+
251
+ if (totalUnits + costUnits > FREE_TIER_QUOTAS.MAX_TRIAL_REQUEST_UNITS) {
252
+ return {
253
+ allowed: false,
254
+ reason: `Total trial request unit limit reached (${totalUnits}/${FREE_TIER_QUOTAS.MAX_TRIAL_REQUEST_UNITS}). Upgrade your plan.`,
255
+ costUnits,
256
+ };
257
+ }
258
+
259
+ const dailyUnits = params.dailyUnitsUsed ?? 0;
260
+ if (dailyUnits + costUnits > FREE_TIER_QUOTAS.MAX_DAILY_REQUEST_UNITS) {
261
+ return {
262
+ allowed: false,
263
+ reason: `Daily request quota limit reached (${dailyUnits}/${FREE_TIER_QUOTAS.MAX_DAILY_REQUEST_UNITS} units/day). Quota resets tomorrow.`,
264
+ costUnits,
265
+ };
266
+ }
267
+
268
+ return { allowed: true, costUnits };
269
+ }
270
+
271
+ static checkQueryAllowed(dailyQueriesCount = 0): { allowed: boolean; reason?: string } {
272
+ if (dailyQueriesCount >= FREE_TIER_QUOTAS.MAX_DAILY_REQUEST_UNITS) {
273
+ return {
274
+ allowed: false,
275
+ reason: `Daily query limit reached (${FREE_TIER_QUOTAS.MAX_DAILY_REQUEST_UNITS}/day). Quota resets tomorrow.`,
276
+ };
277
+ }
278
+
279
+ return { allowed: true };
280
+ }
281
+ }
@@ -2,31 +2,79 @@ import { SDK_VERSION } from '../version';
2
2
  import { SDKVersionUnsupportedError, LicenseValidationError } from '../exceptions';
3
3
  import { LicenseVerifier } from './LicenseVerifier';
4
4
 
5
+ /**
6
+ * Input contract for `LicenseValidator.validate()`.
7
+ *
8
+ * At least one source must yield a license key: `licenseKey` field, an
9
+ * `x-license-key` entry in `headers`, or the process environment variables
10
+ * (NEXT_PUBLIC_RETRIVORA_LICENSE_KEY / RETRIVORA_LICENSE_KEY).
11
+ */
5
12
  export interface LicenseValidationRequest {
13
+ /** Explicit `rtv_`-prefixed license key passed as a prop (highest priority). */
6
14
  licenseKey: string;
15
+ /** Project ID the caller is running under, checked for license binding match. */
7
16
  projectId?: string;
17
+ /** SDK version reported for minimum-supported-version enforcement (defaults to SDK_VERSION). */
8
18
  sdkVersion?: string;
19
+ /** SDK package identifier sent to server for observability. */
9
20
  sdk?: string;
21
+ /** browser | node — reported for observability only. */
10
22
  platform?: string;
23
+ /** Override base URL of the Retrivora API endpoint. Defaults to env or '/api/retrivora'. */
11
24
  retrivoraApiBase?: string;
25
+ /** Custom headers that may contain `x-license-key` or `Authorization: Bearer`. */
12
26
  headers?: Record<string, string>;
13
27
  }
14
28
 
29
+ /**
30
+ * Response contract returned from `LicenseValidator.validate()`.
31
+ *
32
+ * Status enumerations are ordered — anything other than ACTIVE means the
33
+ * widget/chat must be disabled and must not pass server-side handler checks.
34
+ */
15
35
  export interface LicenseValidationResponse {
36
+ /** True only when `licenseStatus` is ACTIVE and SDK version is supported. */
16
37
  valid: boolean;
38
+ /** Lifecycle state of the license record. TERMINATED/SUSPENDED/EXPIRED/REVOKED must block chat. */
17
39
  licenseStatus: 'ACTIVE' | 'SUSPENDED' | 'TERMINATED' | 'EXPIRED' | 'REVOKED';
40
+ /** Minimum SDK version that the server will accept (semver-compare). */
18
41
  minimumSupportedVersion: string;
42
+ /** Latest generally-available SDK version (for upgrade prompts). */
19
43
  latestVersion: string;
44
+ /** True when the SDK version is older than minimumSupportedVersion — UI must force upgrade. */
20
45
  forceUpgrade: boolean;
46
+ /** RFC3339 expiration timestamp or null for perpetual licenses. */
21
47
  expiresAt: string | null;
48
+ /** Human-readable status/reason string, suitable for tooltips/toasts. */
22
49
  message: string;
23
50
  }
24
51
 
52
+ /** Internal cache entry shape for successful validations only (failures are never cached). */
25
53
  interface CachedSuccessValidation {
26
54
  response: LicenseValidationResponse;
27
55
  timestamp: number;
28
56
  }
29
57
 
58
+ /**
59
+ * Client-side orchestrator for license validation.
60
+ *
61
+ * Flow:
62
+ * 1. Resolve the license key (prop → header → env)
63
+ * 2. Return any fresh (< 5 min old) successful cached result
64
+ * 3. POST to `{retrivoraApiBase}/v1/license/validate` for server-side DB-backed status
65
+ * 4. On network or HTTP error, fall back to zero-latency local RSA signature
66
+ * verification via `LicenseVerifier.verify()`.
67
+ *
68
+ * Caching policy:
69
+ * - ONLY successful (valid + ACTIVE + !forceUpgrade) responses are cached
70
+ * - Any failed validation immediately purges the cache entry
71
+ * - TTL is 5 minutes for success cache to allow TERMINATED/REVOKED status
72
+ * changes on the server to propagate quickly.
73
+ *
74
+ * Thread safety: Singleton pattern via `getInstance()`. Used by ChatWidget,
75
+ * useRagChat hook, and the server-side `createLicenseHandler` indirectly through
76
+ * its own `LicenseVerifier.verify()` path.
77
+ */
30
78
  export class LicenseValidator {
31
79
  private static instance: LicenseValidator;
32
80
  private cache: Map<string, CachedSuccessValidation> = new Map();
@@ -34,6 +82,7 @@ export class LicenseValidator {
34
82
 
35
83
  private constructor() {}
36
84
 
85
+ /** @returns Singleton instance of the validator (shared cache across all consumers). */
37
86
  public static getInstance(): LicenseValidator {
38
87
  if (!LicenseValidator.instance) {
39
88
  LicenseValidator.instance = new LicenseValidator();
@@ -42,7 +91,12 @@ export class LicenseValidator {
42
91
  }
43
92
 
44
93
  /**
45
- * Invalidate local validation cache for a license key
94
+ * Purge entries from the local success cache.
95
+ *
96
+ * @param licenseKey - Optional. If provided, only that key's cache entry is
97
+ * removed. If omitted the entire cache is cleared.
98
+ * Callers use this after any 401/403 to ensure the next
99
+ * validate() call re-validates against the server.
46
100
  */
47
101
  public purgeCache(licenseKey?: string): void {
48
102
  if (licenseKey) {
@@ -53,7 +107,17 @@ export class LicenseValidator {
53
107
  }
54
108
 
55
109
  /**
56
- * Validate license status and SDK version against Retrivora License Service
110
+ * Primary validation entry-point for UI / hook consumers.
111
+ *
112
+ * Resolves the license key, consults the local success cache, performs a
113
+ * server POST for authoritative status, and falls back to local RSA
114
+ * signature verification if the server can't be reached.
115
+ *
116
+ * @throws LicenseValidationError - When no key is provided OR both the
117
+ * server call AND the local-RSA fallback report an invalid/expired key.
118
+ * @throws SDKVersionUnsupportedError - When server reports forceUpgrade=true.
119
+ * @returns A `LicenseValidationResponse` that the UI can use to enable or
120
+ * lock the chat widget.
57
121
  */
58
122
  public async validate(request: LicenseValidationRequest): Promise<LicenseValidationResponse> {
59
123
  const {
@@ -1,15 +1,43 @@
1
1
  import * as crypto from 'crypto';
2
2
  import { ConfigurationException } from '../exceptions';
3
3
 
4
+ /**
5
+ * Decoded payload of a Retrivora license JWT.
6
+ *
7
+ * Retrivora licenses are signed with RS256 using a key held exclusively by
8
+ * Retrivora SaaS. They carry the project binding, the expiration time, and
9
+ * the customer tier, which the SDK uses to enforce per-tier feature limits
10
+ * (vector DB provider whitelist).
11
+ */
4
12
  export interface LicensePayload {
13
+ /** Project ID this license is bound to — used to prevent cross-tenant reuse. */
5
14
  projectId: string;
6
- expiresAt: number; // UNIX timestamp in seconds
15
+ /** UNIX timestamp in seconds after which the license must be considered expired. */
16
+ expiresAt: number;
17
+ /** Customer tier (hobby | pro | enterprise). */
7
18
  tier: string;
19
+ /** Optional: License lifecycle status propagated by the server validator (ACTIVE/TERMINATED/etc). */
20
+ licenseStatus?: string;
8
21
  }
9
22
 
10
23
  /**
11
- * LicenseVerifier — handles cryptographic validation of signed JWT license keys.
12
- * Enables zero-latency local license validation without external network calls.
24
+ * Zero-latency cryptographic license verifier.
25
+ *
26
+ * Responsibilities:
27
+ * 1. Parse the `rtv_`-prefixed JWT (base64url header.payload.signature)
28
+ * 2. Verify the RS256 signature against Retrivora's embedded PUBLIC_KEY
29
+ * 3. Match the license's projectId to the currently configured projectId
30
+ * 4. Enforce expiration (current UNIX seconds > expiresAt)
31
+ * 5. Enforce per-tier vector DB provider whitelist in production
32
+ *
33
+ * Fail-open policy: In non-production environments a missing license key
34
+ * produces a console-warning and a synthetic 24h "hobby" payload so that
35
+ * local development continues to work without signing up. In production
36
+ * any missing/invalid key throws `ConfigurationException`.
37
+ *
38
+ * Caching: Cryptographic verification is CPU-expensive — successful results
39
+ * are cached for 60 seconds keyed by (licenseKey, projectId, provider,
40
+ * publicKeyOverride). Cache hits still re-verify expiresAt.
13
41
  */
14
42
  export class LicenseVerifier {
15
43
  // A simple in-memory cache to save CPU overhead of cryptographic signature checks.
@@ -7,6 +7,7 @@ import { IngestDocument, ChatResponse } from '../types';
7
7
  import { ChatMessage } from '../types';
8
8
  import { LicenseVerifier } from './LicenseVerifier';
9
9
  import { wrapError, RetrivoraErrorCode } from '../exceptions';
10
+ import { FreeTierLimitsGuard } from './FreeTierLimitsGuard';
10
11
 
11
12
  /**
12
13
  * VectorPlugin — main orchestrator class.
@@ -78,6 +79,13 @@ export class VectorPlugin {
78
79
  );
79
80
  }
80
81
 
82
+ private estimateInputTokens(message: string, history: ChatMessage[] = []): number {
83
+ const allContent =
84
+ message.length +
85
+ history.reduce((acc, m) => acc + ((m as any)?.content?.length || (m as any)?.text?.length || 0), 0);
86
+ return Math.ceil(allContent / 4);
87
+ }
88
+
81
89
  /**
82
90
  * Run a chat query.
83
91
  */
@@ -88,6 +96,14 @@ export class VectorPlugin {
88
96
  throw wrapError(err, 'CONFIGURATION_ERROR');
89
97
  }
90
98
  try {
99
+ const inputTokens = this.estimateInputTokens(message, history);
100
+ const tokenCheck = (FreeTierLimitsGuard as any).checkRequestAllowed({
101
+ operationType: 'chat',
102
+ inputTokens,
103
+ });
104
+ if (!tokenCheck.allowed && tokenCheck.reason?.toLowerCase().includes('token')) {
105
+ throw wrapError(new Error(tokenCheck.reason), 'RATE_LIMITED');
106
+ }
91
107
  return await this.pipeline.ask(message, history, namespace);
92
108
  } catch (err) {
93
109
  const msg = String(err);
@@ -95,6 +111,9 @@ export class VectorPlugin {
95
111
  if (msg.includes('Embed') || msg.includes('embed')) {
96
112
  defaultCode = 'EMBEDDING_FAILED';
97
113
  }
114
+ if (msg.toLowerCase().includes('token limit')) {
115
+ defaultCode = 'RATE_LIMITED';
116
+ }
98
117
  throw wrapError(err, defaultCode);
99
118
  }
100
119
  }
@@ -109,6 +128,14 @@ export class VectorPlugin {
109
128
  throw wrapError(err, 'CONFIGURATION_ERROR');
110
129
  }
111
130
  try {
131
+ const inputTokens = this.estimateInputTokens(message, history);
132
+ const tokenCheck = (FreeTierLimitsGuard as any).checkRequestAllowed({
133
+ operationType: 'chat_stream',
134
+ inputTokens,
135
+ });
136
+ if (!tokenCheck.allowed && tokenCheck.reason?.toLowerCase().includes('token')) {
137
+ throw wrapError(new Error(tokenCheck.reason), 'RATE_LIMITED');
138
+ }
112
139
  const stream = this.pipeline.askStream(message, history, namespace);
113
140
  for await (const chunk of stream) {
114
141
  yield chunk;
@@ -119,6 +146,9 @@ export class VectorPlugin {
119
146
  if (msg.includes('Embed') || msg.includes('embed')) {
120
147
  defaultCode = 'EMBEDDING_FAILED';
121
148
  }
149
+ if (msg.toLowerCase().includes('token limit')) {
150
+ defaultCode = 'RATE_LIMITED';
151
+ }
122
152
  throw wrapError(err, defaultCode);
123
153
  }
124
154
  }