mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 06:17:21 +02:00
🔬 ci: Add TypeScript Type Checks to Backend Workflow and Fix All Type Errors (#12451)
* fix(data-schemas): resolve TypeScript strict type check errors in source files - Constrain ConfigSection to string keys via `string & keyof TCustomConfig` - Replace broken `z` import from data-provider with TCustomConfig derivation - Add `_id: Types.ObjectId` to IUser matching other Document interfaces - Add `federatedTokens` and `openidTokens` optional fields to IUser - Type mongoose model accessors as `Model<IRole>` and `Model<IUser>` - Widen `getPremiumRate` param to accept `number | null` - Widen `bulkWriteAclEntries` ops to untyped `AnyBulkWriteOperation[]` - Fix `getUserPrincipals` return type to use `PrincipalType` enum - Add non-null assertions for `connection.db` in migration files - Import DailyRotateFile constructor directly instead of relying on broken module augmentation across mismatched node_modules trees - Add winston-daily-rotate-file as devDependency for type resolution * fix(data-schemas): resolve TypeScript type errors in test files - Replace arbitrary test keys with valid TCustomConfig properties in config.spec - Use non-null assertions for permission objects in role.methods.spec - Replace `.SHARED_GLOBAL` access with `.not.toHaveProperty()` for legacy field - Add non-null assertions for balance, writeRate, readRate in spendTokens.spec - Update mock user _id to use ObjectId in user.test - Remove unused Schema import in tenantIndexes.spec * fix(api): resolve TypeScript strict type check errors across source and test files - Widen getUserPrincipals dep type in capabilities middleware - Fix federatedTokens type in createSafeUser return - Use proper mock req type for read-only properties in preAuthTenant.spec - Replace `as IUser` casts with ObjectId-typed mocks in openid/oidc specs - Use TokenExchangeMethodEnum values instead of string literals in MCP specs - Fix SessionStore type compatibility in sessionCache specs - Replace `catch (error: any)` with `(error as Error)` in redis specs - Remove invalid properties from test data in initialize and MCP specs - Add String.prototype.isWellFormed declaration for sanitizeTitle spec * fix(client): resolve TypeScript type errors in shared client components - Add default values for destructured bindings in OGDialogTemplate - Replace broken ExtendedFile import with inline type in FileIcon * ci: add TypeScript type-check job to backend review workflow Add a `typecheck` job that runs `tsc --noEmit` on all four TypeScript workspaces (data-provider, data-schemas, @librechat/api, @librechat/client) after the build step. Catches type errors that rollup builds may miss. * fix(data-schemas): add local type declaration for DailyRotateFile transport The `winston-daily-rotate-file` package ships a module augmentation for `winston/lib/winston/transports`, but it fails when winston and winston-daily-rotate-file resolve from different node_modules trees (which happens in this monorepo due to npm hoisting). Add a local `.d.ts` declaration that augments the same module path from within data-schemas' compilation unit, so `tsc --noEmit` passes while keeping the original runtime pattern (`new winston.transports.DailyRotateFile`). * fix: address code review findings from PR #12451 - Restore typed `AnyBulkWriteOperation<AclEntry>[]` on bulkWriteAclEntries, cast to untyped only at the tenantSafeBulkWrite call site (Finding 1) - Type `findUser` model accessor consistently with `findUsers` (Finding 2) - Replace inline `import('mongoose').ClientSession` with top-level import type - Use `toHaveLength` for spy assertions in playwright-expect spec file - Replace numbered Record casts with `.not.toHaveProperty()` in role.methods.spec for SHARED_GLOBAL assertions - Use per-test ObjectIds instead of shared testUserId in openid.spec - Replace inline `import()` type annotations with top-level SessionData import in sessionCache spec - Remove extraneous blank line in user.ts searchUsers * refactor: address remaining review findings (4–7) - Extract OIDCTokens interface in user.ts; deduplicate across IUser fields and oidc.ts FederatedTokens (Finding 4) - Move String.isWellFormed declaration from spec file to project-level src/types/es2024-string.d.ts (Finding 5) - Replace verbose `= undefined` defaults in OGDialogTemplate with null coalescing pattern (Finding 6) - Replace `Record<string, unknown>` TestConfig with named interface containing explicit test fields (Finding 7)
This commit is contained in:
parent
d5c7d9f525
commit
fda1bfc3cc
38 changed files with 406 additions and 233 deletions
59
.github/workflows/backend-review.yml
vendored
59
.github/workflows/backend-review.yml
vendored
|
|
@ -97,6 +97,65 @@ jobs:
|
|||
path: packages/api/dist
|
||||
retention-days: 2
|
||||
|
||||
typecheck:
|
||||
name: TypeScript type checks
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 20.19
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.19'
|
||||
|
||||
- name: Restore node_modules cache
|
||||
id: cache-node-modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
api/node_modules
|
||||
packages/api/node_modules
|
||||
packages/data-provider/node_modules
|
||||
packages/data-schemas/node_modules
|
||||
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Download data-provider build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-data-provider
|
||||
path: packages/data-provider/dist
|
||||
|
||||
- name: Download data-schemas build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-data-schemas
|
||||
path: packages/data-schemas/dist
|
||||
|
||||
- name: Download api build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-api
|
||||
path: packages/api/dist
|
||||
|
||||
- name: Type check data-provider
|
||||
run: npx tsc --noEmit -p packages/data-provider/tsconfig.json
|
||||
|
||||
- name: Type check data-schemas
|
||||
run: npx tsc --noEmit -p packages/data-schemas/tsconfig.json
|
||||
|
||||
- name: Type check @librechat/api
|
||||
run: npx tsc --noEmit -p packages/api/tsconfig.json
|
||||
|
||||
- name: Type check @librechat/client
|
||||
run: npx tsc --noEmit -p packages/client/tsconfig.json
|
||||
|
||||
circular-deps:
|
||||
name: Circular dependency checks
|
||||
needs: build
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { Response } from 'express';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
import { createAdminConfigHandlers } from './config';
|
||||
|
||||
function mockReq(overrides = {}) {
|
||||
|
|
@ -7,23 +9,30 @@ function mockReq(overrides = {}) {
|
|||
body: {},
|
||||
query: {},
|
||||
...overrides,
|
||||
};
|
||||
} as Partial<ServerRequest> as ServerRequest;
|
||||
}
|
||||
|
||||
interface MockRes {
|
||||
statusCode: number;
|
||||
body: undefined | { config?: unknown; error?: string; [key: string]: unknown };
|
||||
status: jest.Mock;
|
||||
json: jest.Mock;
|
||||
}
|
||||
|
||||
function mockRes() {
|
||||
const res = {
|
||||
const res: MockRes = {
|
||||
statusCode: 200,
|
||||
body: undefined,
|
||||
status: jest.fn((code) => {
|
||||
status: jest.fn((code: number) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
json: jest.fn((data) => {
|
||||
json: jest.fn((data: MockRes['body']) => {
|
||||
res.body = data;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return res;
|
||||
return res as Partial<Response> as Response & MockRes;
|
||||
}
|
||||
|
||||
function createHandlers(overrides = {}) {
|
||||
|
|
@ -93,7 +102,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
await handlers.getConfig(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.config).toEqual(config);
|
||||
expect(res.body!.config).toEqual(config);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid principalType', async () => {
|
||||
|
|
@ -191,7 +200,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
await handlers.deleteConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toContain('query parameter');
|
||||
expect(res.body!.error).toContain('query parameter');
|
||||
});
|
||||
|
||||
it('rejects unsafe field paths', async () => {
|
||||
|
|
@ -408,7 +417,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
await handlers.getBaseConfig(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.config).toEqual({ interface: { endpointsMenu: true } });
|
||||
expect(res.body!.config).toEqual({ interface: { endpointsMenu: true } });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import { createAppConfigService } from './service';
|
||||
|
||||
/** Extends AppConfig with mock fields used by merge behavior tests. */
|
||||
interface TestConfig extends AppConfig {
|
||||
restricted?: boolean;
|
||||
x?: string;
|
||||
interface?: { endpointsMenu?: boolean; [key: string]: boolean | undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock cache that simulates Keyv's namespace behavior.
|
||||
* Keyv stores keys internally as `namespace:key` but its API (get/set/delete)
|
||||
|
|
@ -18,7 +26,9 @@ function createMockCache(namespace = 'app_config') {
|
|||
return Promise.resolve(true);
|
||||
}),
|
||||
/** Mimic Keyv's opts.store structure for key enumeration in clearOverrideCache */
|
||||
opts: { store: { keys: () => store.keys() } },
|
||||
opts: { store: { keys: () => store.keys() } } as {
|
||||
store?: { keys: () => IterableIterator<string> };
|
||||
},
|
||||
_store: store,
|
||||
};
|
||||
}
|
||||
|
|
@ -123,8 +133,10 @@ describe('createAppConfigService', () => {
|
|||
|
||||
const config = await getAppConfig({ role: 'ADMIN' });
|
||||
|
||||
expect(config.interface.endpointsMenu).toBe(false);
|
||||
expect(config.endpoints).toEqual(['openAI']);
|
||||
// Test data uses mock fields that don't exist on AppConfig to verify merge behavior
|
||||
const merged = config as TestConfig;
|
||||
expect(merged.interface?.endpointsMenu).toBe(false);
|
||||
expect(merged.endpoints).toEqual(['openAI']);
|
||||
});
|
||||
|
||||
it('caches merged result with TTL', async () => {
|
||||
|
|
@ -199,7 +211,7 @@ describe('createAppConfigService', () => {
|
|||
const config = await getAppConfig({ role: 'ADMIN' });
|
||||
|
||||
expect(mockGetConfigs).toHaveBeenCalledTimes(2);
|
||||
expect((config as Record<string, unknown>).restricted).toBe(true);
|
||||
expect((config as TestConfig).restricted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not short-circuit other users when one user has no overrides', async () => {
|
||||
|
|
@ -216,7 +228,7 @@ describe('createAppConfigService', () => {
|
|||
const config = await getAppConfig({ role: 'ADMIN' });
|
||||
|
||||
expect(mockGetConfigs).toHaveBeenCalledTimes(2);
|
||||
expect((config as Record<string, unknown>).x).toBe('admin-only');
|
||||
expect((config as TestConfig).x).toBe('admin-only');
|
||||
});
|
||||
|
||||
it('falls back to base config on getApplicableConfigs error', async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { Types } from 'mongoose';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { IUser, UserMethods } from '@librechat/data-schemas';
|
||||
import { findOpenIDUser } from './openid';
|
||||
|
||||
function newId() {
|
||||
return new Types.ObjectId();
|
||||
}
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: {
|
||||
|
|
@ -24,7 +29,7 @@ describe('findOpenIDUser', () => {
|
|||
describe('Primary condition searches', () => {
|
||||
it('should find user by openidId', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
provider: 'openid',
|
||||
openidId: 'openid_123',
|
||||
email: 'user@example.com',
|
||||
|
|
@ -51,7 +56,7 @@ describe('findOpenIDUser', () => {
|
|||
|
||||
it('should find user by idOnTheSource', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
provider: 'openid',
|
||||
idOnTheSource: 'source_123',
|
||||
email: 'user@example.com',
|
||||
|
|
@ -78,7 +83,7 @@ describe('findOpenIDUser', () => {
|
|||
|
||||
it('should find user by both openidId and idOnTheSource', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
provider: 'openid',
|
||||
openidId: 'openid_123',
|
||||
idOnTheSource: 'source_123',
|
||||
|
|
@ -109,16 +114,14 @@ describe('findOpenIDUser', () => {
|
|||
describe('Email-based searches', () => {
|
||||
it('should find user by email when primary conditions fail and openidId matches', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
provider: 'openid',
|
||||
openidId: 'openid_123',
|
||||
email: 'user@example.com',
|
||||
username: 'testuser',
|
||||
} as IUser;
|
||||
|
||||
mockFindUser
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(mockUser);
|
||||
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
openidId: 'openid_123',
|
||||
|
|
@ -179,7 +182,7 @@ describe('findOpenIDUser', () => {
|
|||
describe('Provider conflict handling', () => {
|
||||
it('should return error when user has different provider', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
provider: 'google',
|
||||
email: 'user@example.com',
|
||||
username: 'testuser',
|
||||
|
|
@ -204,16 +207,14 @@ describe('findOpenIDUser', () => {
|
|||
|
||||
it('should reject email fallback when existing openidId does not match token sub', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
provider: 'openid',
|
||||
openidId: 'openid_456',
|
||||
email: 'user@example.com',
|
||||
username: 'testuser',
|
||||
} as IUser;
|
||||
|
||||
mockFindUser
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(mockUser);
|
||||
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
openidId: 'openid_123',
|
||||
|
|
@ -230,16 +231,14 @@ describe('findOpenIDUser', () => {
|
|||
|
||||
it('should allow email fallback when existing openidId matches token sub', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
provider: 'openid',
|
||||
openidId: 'openid_123',
|
||||
email: 'user@example.com',
|
||||
username: 'testuser',
|
||||
} as IUser;
|
||||
|
||||
mockFindUser
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(mockUser);
|
||||
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
openidId: 'openid_123',
|
||||
|
|
@ -258,7 +257,7 @@ describe('findOpenIDUser', () => {
|
|||
describe('User migration scenarios', () => {
|
||||
it('should prepare user for migration when email exists without openidId', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
email: 'user@example.com',
|
||||
username: 'testuser',
|
||||
// No provider and no openidId - needs migration
|
||||
|
|
@ -287,16 +286,14 @@ describe('findOpenIDUser', () => {
|
|||
|
||||
it('should reject when user already has a different openidId', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
provider: 'openid',
|
||||
openidId: 'existing_openid',
|
||||
email: 'user@example.com',
|
||||
username: 'testuser',
|
||||
} as IUser;
|
||||
|
||||
mockFindUser
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(mockUser);
|
||||
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
openidId: 'openid_123',
|
||||
|
|
@ -313,16 +310,14 @@ describe('findOpenIDUser', () => {
|
|||
|
||||
it('should reject when user has no provider but a different openidId', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
openidId: 'existing_openid',
|
||||
email: 'user@example.com',
|
||||
username: 'testuser',
|
||||
// No provider field — tests a different branch than openid-provider mismatch
|
||||
} as IUser;
|
||||
|
||||
mockFindUser
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(mockUser);
|
||||
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
openidId: 'openid_123',
|
||||
|
|
@ -422,16 +417,14 @@ describe('findOpenIDUser', () => {
|
|||
|
||||
it('should pass email to findUser for case-insensitive lookup (findUser handles normalization)', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
provider: 'openid',
|
||||
openidId: 'openid_123',
|
||||
email: 'user@example.com',
|
||||
username: 'testuser',
|
||||
} as IUser;
|
||||
|
||||
mockFindUser
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(mockUser);
|
||||
mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser);
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
openidId: 'openid_123',
|
||||
|
|
@ -460,7 +453,7 @@ describe('findOpenIDUser', () => {
|
|||
|
||||
it('should reject email fallback when openidId is empty and user has a stored openidId', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
_id: newId(),
|
||||
provider: 'openid',
|
||||
openidId: 'existing-real-id',
|
||||
email: 'user@example.com',
|
||||
|
|
|
|||
|
|
@ -1,33 +1,36 @@
|
|||
interface SessionData {
|
||||
import type { MemoryStore, SessionData } from 'express-session';
|
||||
import type { RedisStore as ConnectRedis } from 'connect-redis';
|
||||
|
||||
interface TestSessionData {
|
||||
[key: string]: unknown;
|
||||
cookie?: { maxAge: number };
|
||||
user?: { id: string; name: string };
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface SessionStore {
|
||||
prefix?: string;
|
||||
set: (id: string, data: SessionData, callback?: (err?: Error) => void) => void;
|
||||
get: (id: string, callback: (err: Error | null, data?: SessionData | null) => void) => void;
|
||||
destroy: (id: string, callback?: (err?: Error) => void) => void;
|
||||
touch: (id: string, data: SessionData, callback?: (err?: Error) => void) => void;
|
||||
on?: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
}
|
||||
type CacheSessionStore = MemoryStore | ConnectRedis;
|
||||
|
||||
describe('sessionCache', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
// Helper to make session stores async
|
||||
const asyncStore = (store: SessionStore) => ({
|
||||
set: (id: string, data: SessionData) =>
|
||||
new Promise<void>((resolve) => store.set(id, data, () => resolve())),
|
||||
// Helper to make session stores async — uses generic store type to bridge
|
||||
// between MemoryStore/ConnectRedis and the test's relaxed SessionData shape.
|
||||
// The store methods accept express-session's SessionData but test data is
|
||||
// intentionally simpler; the cast bridges the gap for integration tests.
|
||||
const asyncStore = (store: CacheSessionStore) => ({
|
||||
set: (id: string, data: TestSessionData) =>
|
||||
new Promise<void>((resolve) =>
|
||||
store.set(id, data as Partial<SessionData> as SessionData, () => resolve()),
|
||||
),
|
||||
get: (id: string) =>
|
||||
new Promise<SessionData | null | undefined>((resolve) =>
|
||||
store.get(id, (_, data) => resolve(data)),
|
||||
new Promise<TestSessionData | null | undefined>((resolve) =>
|
||||
store.get(id, (_, data) => resolve(data as TestSessionData | null | undefined)),
|
||||
),
|
||||
destroy: (id: string) => new Promise<void>((resolve) => store.destroy(id, () => resolve())),
|
||||
touch: (id: string, data: SessionData) =>
|
||||
new Promise<void>((resolve) => store.touch(id, data, () => resolve())),
|
||||
touch: (id: string, data: TestSessionData) =>
|
||||
new Promise<void>((resolve) =>
|
||||
store.touch(id, data as Partial<SessionData> as SessionData, () => resolve()),
|
||||
),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -66,11 +69,11 @@ describe('sessionCache', () => {
|
|||
// Verify it returns a ConnectRedis instance
|
||||
expect(store).toBeDefined();
|
||||
expect(store.constructor.name).toBe('RedisStore');
|
||||
expect(store.prefix).toBe('test-sessions:');
|
||||
expect((store as CacheSessionStore & { prefix: string }).prefix).toBe('test-sessions:');
|
||||
|
||||
// Test session operations
|
||||
const sessionId = 'sess:123456';
|
||||
const sessionData: SessionData = {
|
||||
const sessionData: TestSessionData = {
|
||||
user: { id: 'user123', name: 'Test User' },
|
||||
cookie: { maxAge: 3600000 },
|
||||
};
|
||||
|
|
@ -107,7 +110,7 @@ describe('sessionCache', () => {
|
|||
|
||||
// Test session operations
|
||||
const sessionId = 'mem:789012';
|
||||
const sessionData: SessionData = {
|
||||
const sessionData: TestSessionData = {
|
||||
user: { id: 'user456', name: 'Memory User' },
|
||||
cookie: { maxAge: 3600000 },
|
||||
};
|
||||
|
|
@ -135,8 +138,8 @@ describe('sessionCache', () => {
|
|||
const store1 = cacheFactory.sessionCache('namespace1');
|
||||
const store2 = cacheFactory.sessionCache('namespace2:');
|
||||
|
||||
expect(store1.prefix).toBe('namespace1:');
|
||||
expect(store2.prefix).toBe('namespace2:');
|
||||
expect((store1 as CacheSessionStore & { prefix: string }).prefix).toBe('namespace1:');
|
||||
expect((store2 as CacheSessionStore & { prefix: string }).prefix).toBe('namespace2:');
|
||||
});
|
||||
|
||||
test('should register error handler for Redis connection', async () => {
|
||||
|
|
@ -171,7 +174,7 @@ describe('sessionCache', () => {
|
|||
}
|
||||
|
||||
const sessionId = 'ttl:12345';
|
||||
const sessionData: SessionData = { userId: 'ttl-user' };
|
||||
const sessionData: TestSessionData = { userId: 'ttl-user' };
|
||||
const async = asyncStore(store);
|
||||
|
||||
// Set session with short TTL
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ describe('redisClients Integration Tests', () => {
|
|||
if (keys.length > 0) {
|
||||
await ioredisClient.del(...keys);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn('Error cleaning up test keys:', error.message);
|
||||
} catch (error) {
|
||||
console.warn('Error cleaning up test keys:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,8 +70,8 @@ describe('redisClients Integration Tests', () => {
|
|||
if (ioredisClient.status === 'ready') {
|
||||
ioredisClient.disconnect();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn('Error disconnecting ioredis client:', error.message);
|
||||
} catch (error) {
|
||||
console.warn('Error disconnecting ioredis client:', (error as Error).message);
|
||||
}
|
||||
ioredisClient = null;
|
||||
}
|
||||
|
|
@ -80,8 +80,8 @@ describe('redisClients Integration Tests', () => {
|
|||
try {
|
||||
// Try to disconnect - keyv/redis client doesn't have an isReady property
|
||||
await keyvRedisClient.disconnect();
|
||||
} catch (error: any) {
|
||||
console.warn('Error disconnecting keyv redis client:', error.message);
|
||||
} catch (error) {
|
||||
console.warn('Error disconnecting keyv redis client:', (error as Error).message);
|
||||
}
|
||||
keyvRedisClient = null;
|
||||
}
|
||||
|
|
@ -138,7 +138,11 @@ describe('redisClients Integration Tests', () => {
|
|||
test('should connect and perform set/get/delete operations', async () => {
|
||||
const clients = await import('../redisClients');
|
||||
keyvRedisClient = clients.keyvRedisClient;
|
||||
await testRedisOperations(keyvRedisClient!, 'keyv-single', clients.keyvRedisClientReady!);
|
||||
await testRedisOperations(
|
||||
keyvRedisClient!,
|
||||
'keyv-single',
|
||||
clients.keyvRedisClientReady!.then(() => undefined),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -150,7 +154,11 @@ describe('redisClients Integration Tests', () => {
|
|||
|
||||
const clients = await import('../redisClients');
|
||||
keyvRedisClient = clients.keyvRedisClient;
|
||||
await testRedisOperations(keyvRedisClient!, 'keyv-cluster', clients.keyvRedisClientReady!);
|
||||
await testRedisOperations(
|
||||
keyvRedisClient!,
|
||||
'keyv-cluster',
|
||||
clients.keyvRedisClientReady!.then(() => undefined),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ describe('initializeCustom – Agents API user key resolution', () => {
|
|||
userApiKey: 'sk-user-key',
|
||||
});
|
||||
// Simulate Agents API request body (no `key` field)
|
||||
params.req.body = { model: 'agent_123', messages: [] };
|
||||
params.req.body = { model: 'agent_123' };
|
||||
|
||||
await initializeCustom(params);
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ describe('initializeCustom – Agents API user key resolution', () => {
|
|||
baseURL: AuthType.USER_PROVIDED,
|
||||
userBaseURL: 'https://user-api.example.com/v1',
|
||||
});
|
||||
params.req.body = { model: 'agent_123', messages: [] };
|
||||
params.req.body = { model: 'agent_123' };
|
||||
|
||||
await initializeCustom(params);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { createHash } from 'crypto';
|
||||
import { Keyv } from 'keyv';
|
||||
import { TokenExchangeMethodEnum } from 'librechat-data-provider';
|
||||
import { MCPTokenStorage, MCPOAuthHandler } from '~/mcp/oauth';
|
||||
import { FlowStateManager } from '~/flow/manager';
|
||||
import { createOAuthMCPServer, MockKeyv, InMemoryTokenStore } from './helpers/oauthTestServer';
|
||||
|
|
@ -94,7 +95,7 @@ describe('MCP OAuth Flow — Real HTTP Server', () => {
|
|||
token_url: `${server.url}token`,
|
||||
client_id: clientInfo.client_id,
|
||||
client_secret: clientInfo.client_secret,
|
||||
token_exchange_method: 'DefaultPost',
|
||||
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -133,7 +134,7 @@ describe('MCP OAuth Flow — Real HTTP Server', () => {
|
|||
{
|
||||
token_url: `${rotatingServer.url}token`,
|
||||
client_id: 'anon',
|
||||
token_exchange_method: 'DefaultPost',
|
||||
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -157,7 +158,7 @@ describe('MCP OAuth Flow — Real HTTP Server', () => {
|
|||
{
|
||||
token_url: `${server.url}token`,
|
||||
client_id: 'anon',
|
||||
token_exchange_method: 'DefaultPost',
|
||||
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
|
@ -414,7 +415,7 @@ describe('MCP OAuth Flow — Real HTTP Server', () => {
|
|||
|
||||
const state = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||
expect(state?.status).toBe('COMPLETED');
|
||||
expect(state?.result?.access_token).toBe(tokens.access_token);
|
||||
expect((state?.result as MCPOAuthTokens | undefined)?.access_token).toBe(tokens.access_token);
|
||||
});
|
||||
|
||||
it('should fail flow when authorization code is invalid', async () => {
|
||||
|
|
|
|||
|
|
@ -304,10 +304,10 @@ describe('MCP OAuth allowedDomains SSRF exemption for admin-trusted hosts', () =
|
|||
});
|
||||
|
||||
it('should allow private revocationEndpoint when hostname is in allowedDomains', async () => {
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response);
|
||||
const mockFetch = Object.assign(
|
||||
jest.fn().mockResolvedValue({ ok: true, status: 200 } as Response),
|
||||
{ preconnect: jest.fn() },
|
||||
);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mockFetch;
|
||||
|
||||
|
|
@ -333,14 +333,17 @@ describe('MCP OAuth allowedDomains SSRF exemption for admin-trusted hosts', () =
|
|||
});
|
||||
|
||||
it('should allow localhost token_url in refreshOAuthTokens when localhost is in allowedDomains', async () => {
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
} as Response);
|
||||
const mockFetch = Object.assign(
|
||||
jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
} as Response),
|
||||
{ preconnect: jest.fn() },
|
||||
);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mockFetch;
|
||||
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ describe('MCPTokenStorage', () => {
|
|||
serverName: 'srv1',
|
||||
tokens: { access_token: 'at1', token_type: 'Bearer', expires_in: 3600 },
|
||||
createToken: store.createToken,
|
||||
clientInfo: { client_id: 'cid', client_secret: 'csec', redirect_uris: [] },
|
||||
clientInfo: { client_id: 'cid', client_secret: 'csec' },
|
||||
});
|
||||
|
||||
const clientSaved = await store.findToken({
|
||||
|
|
@ -525,7 +525,7 @@ describe('MCPTokenStorage', () => {
|
|||
refresh_token: 'my-refresh-token',
|
||||
},
|
||||
createToken: store.createToken,
|
||||
clientInfo: { client_id: 'cid', client_secret: 'sec', redirect_uris: [] },
|
||||
clientInfo: { client_id: 'cid', client_secret: 'sec' },
|
||||
});
|
||||
|
||||
const result = await MCPTokenStorage.getTokens({
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
* the current SCAN+GET implementation.
|
||||
*/
|
||||
import { expect } from '@playwright/test';
|
||||
import type { RedisClientType } from 'redis';
|
||||
import type { ParsedServerConfig } from '~/mcp/types';
|
||||
|
||||
describe('ServerConfigsCacheRedis Performance Benchmark', () => {
|
||||
|
|
@ -103,7 +104,9 @@ describe('ServerConfigsCacheRedis Performance Benchmark', () => {
|
|||
// Phase 1: SCAN only (key discovery)
|
||||
const scanStart = Date.now();
|
||||
const keys: string[] = [];
|
||||
for await (const key of keyvRedisClient!.scanIterator({ MATCH: pattern })) {
|
||||
for await (const key of (keyvRedisClient as RedisClientType).scanIterator({
|
||||
MATCH: pattern,
|
||||
})) {
|
||||
keys.push(key);
|
||||
}
|
||||
const scanMs = Date.now() - scanStart;
|
||||
|
|
@ -166,7 +169,9 @@ describe('ServerConfigsCacheRedis Performance Benchmark', () => {
|
|||
// Measure SCAN with noise
|
||||
const scanStart = Date.now();
|
||||
const keys: string[] = [];
|
||||
for await (const key of keyvRedisClient!.scanIterator({ MATCH: pattern })) {
|
||||
for await (const key of (keyvRedisClient as RedisClientType).scanIterator({
|
||||
MATCH: pattern,
|
||||
})) {
|
||||
keys.push(key);
|
||||
}
|
||||
const scanMs = Date.now() - scanStart;
|
||||
|
|
@ -299,7 +304,9 @@ describe('ServerConfigsCacheRedis Performance Benchmark', () => {
|
|||
// First, discover keys via SCAN (same for both approaches)
|
||||
const pattern = `*MCP::ServersRegistry::Servers::${ns}:*`;
|
||||
const keys: string[] = [];
|
||||
for await (const key of keyvRedisClient!.scanIterator({ MATCH: pattern })) {
|
||||
for await (const key of (keyvRedisClient as RedisClientType).scanIterator({
|
||||
MATCH: pattern,
|
||||
})) {
|
||||
keys.push(key);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ describe('ServerConfigsCacheRedisAggregateKey Integration Tests', () => {
|
|||
await cache.getAll();
|
||||
|
||||
// Snapshot should be served; Redis should NOT have been called
|
||||
expect(cacheGetSpy).not.toHaveBeenCalled();
|
||||
expect(cacheGetSpy.mock.calls).toHaveLength(0);
|
||||
cacheGetSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
|
@ -330,7 +330,7 @@ describe('ServerConfigsCacheRedisAggregateKey Integration Tests', () => {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const cacheGetSpy = jest.spyOn((cache as any).cache, 'get');
|
||||
const result = await cache.getAll();
|
||||
expect(cacheGetSpy).toHaveBeenCalledTimes(1);
|
||||
expect(cacheGetSpy.mock.calls).toHaveLength(1);
|
||||
expect(Object.keys(result).length).toBe(1);
|
||||
cacheGetSpy.mockRestore();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
import type { PrincipalType } from 'librechat-data-provider';
|
||||
import type { SystemCapability, ConfigSection } from '@librechat/data-schemas';
|
||||
import type { NextFunction, Response } from 'express';
|
||||
import type { Types } from 'mongoose';
|
||||
import type { Types, ClientSession } from 'mongoose';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
|
||||
interface ResolvedPrincipal {
|
||||
|
|
@ -18,7 +18,10 @@ interface ResolvedPrincipal {
|
|||
}
|
||||
|
||||
interface CapabilityDeps {
|
||||
getUserPrincipals: (params: { userId: string; role: string }) => Promise<ResolvedPrincipal[]>;
|
||||
getUserPrincipals: (
|
||||
params: { userId: string | Types.ObjectId; role?: string | null },
|
||||
session?: ClientSession,
|
||||
) => Promise<ResolvedPrincipal[]>;
|
||||
hasCapabilityForPrincipals: (params: {
|
||||
principals: ResolvedPrincipal[];
|
||||
capability: SystemCapability;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ jest.mock('@librechat/data-schemas', () => ({
|
|||
}));
|
||||
|
||||
describe('preAuthTenantMiddleware', () => {
|
||||
let req: Partial<Request>;
|
||||
let req: { headers: Record<string, string | string[] | undefined>; ip?: string; path?: string };
|
||||
let res: Partial<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
|||
4
packages/api/src/types/es2024-string.d.ts
vendored
Normal file
4
packages/api/src/types/es2024-string.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** String.prototype.isWellFormed — ES2024 API, available in Node 20+ but absent from TS 5.3 lib */
|
||||
interface String {
|
||||
isWellFormed(): boolean;
|
||||
}
|
||||
|
|
@ -84,12 +84,12 @@ export function encodeHeaderValue(value: string): string {
|
|||
*/
|
||||
export function createSafeUser(
|
||||
user: IUser | null | undefined,
|
||||
): Partial<SafeUser> & { federatedTokens?: unknown } {
|
||||
): Partial<SafeUser> & { federatedTokens?: IUser['federatedTokens'] } {
|
||||
if (!user) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const safeUser: Partial<SafeUser> & { federatedTokens?: unknown } = {};
|
||||
const safeUser: Partial<SafeUser> & { federatedTokens?: IUser['federatedTokens'] } = {};
|
||||
for (const field of ALLOWED_USER_FIELDS) {
|
||||
if (field in user) {
|
||||
safeUser[field] = user[field];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { TUser } from 'librechat-data-provider';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import type { GraphTokenResolver, GraphTokenOptions } from './graph';
|
||||
import {
|
||||
containsGraphTokenPlaceholder,
|
||||
|
|
@ -94,9 +94,9 @@ describe('Graph Token Utilities', () => {
|
|||
});
|
||||
|
||||
it('should return false for non-object values', () => {
|
||||
expect(recordContainsGraphTokenPlaceholder('string' as unknown as Record<string, string>)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
recordContainsGraphTokenPlaceholder('string' as unknown as Record<string, string>),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ describe('Graph Token Utilities', () => {
|
|||
});
|
||||
|
||||
describe('resolveGraphTokenPlaceholder', () => {
|
||||
const mockUser: Partial<TUser> = {
|
||||
const mockUser: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
openidId: 'oidc-sub-456',
|
||||
|
|
@ -157,7 +157,7 @@ describe('Graph Token Utilities', () => {
|
|||
it('should return original value when no placeholder is present', async () => {
|
||||
const value = 'Bearer static-token';
|
||||
const result = await resolveGraphTokenPlaceholder(value, {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
graphTokenResolver: mockGraphTokenResolver,
|
||||
});
|
||||
expect(result).toBe('Bearer static-token');
|
||||
|
|
@ -174,7 +174,7 @@ describe('Graph Token Utilities', () => {
|
|||
it('should return original value when graphTokenResolver is not provided', async () => {
|
||||
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
|
||||
const result = await resolveGraphTokenPlaceholder(value, {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
});
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
|
|
@ -184,7 +184,7 @@ describe('Graph Token Utilities', () => {
|
|||
|
||||
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
|
||||
const result = await resolveGraphTokenPlaceholder(value, {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
graphTokenResolver: mockGraphTokenResolver,
|
||||
});
|
||||
expect(result).toBe(value);
|
||||
|
|
@ -196,7 +196,7 @@ describe('Graph Token Utilities', () => {
|
|||
|
||||
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
|
||||
const result = await resolveGraphTokenPlaceholder(value, {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
graphTokenResolver: mockGraphTokenResolver,
|
||||
});
|
||||
expect(result).toBe(value);
|
||||
|
|
@ -208,7 +208,7 @@ describe('Graph Token Utilities', () => {
|
|||
|
||||
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
|
||||
const result = await resolveGraphTokenPlaceholder(value, {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
graphTokenResolver: mockGraphTokenResolver,
|
||||
});
|
||||
expect(result).toBe(value);
|
||||
|
|
@ -220,7 +220,7 @@ describe('Graph Token Utilities', () => {
|
|||
|
||||
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
|
||||
const result = await resolveGraphTokenPlaceholder(value, {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
graphTokenResolver: mockGraphTokenResolver,
|
||||
});
|
||||
expect(result).toBe('Bearer resolved-graph-token');
|
||||
|
|
@ -233,7 +233,7 @@ describe('Graph Token Utilities', () => {
|
|||
const value =
|
||||
'Primary: {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}, Secondary: {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
|
||||
const result = await resolveGraphTokenPlaceholder(value, {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
graphTokenResolver: mockGraphTokenResolver,
|
||||
});
|
||||
expect(result).toBe('Primary: resolved-graph-token, Secondary: resolved-graph-token');
|
||||
|
|
@ -242,11 +242,13 @@ describe('Graph Token Utilities', () => {
|
|||
it('should return original value when graph token exchange fails', async () => {
|
||||
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' });
|
||||
mockIsOpenIDTokenValid.mockReturnValue(true);
|
||||
const failingResolver: GraphTokenResolver = jest.fn().mockRejectedValue(new Error('Exchange failed'));
|
||||
const failingResolver: GraphTokenResolver = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Exchange failed'));
|
||||
|
||||
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
|
||||
const result = await resolveGraphTokenPlaceholder(value, {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
graphTokenResolver: failingResolver,
|
||||
});
|
||||
expect(result).toBe(value);
|
||||
|
|
@ -259,7 +261,7 @@ describe('Graph Token Utilities', () => {
|
|||
|
||||
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
|
||||
const result = await resolveGraphTokenPlaceholder(value, {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
graphTokenResolver: emptyResolver,
|
||||
});
|
||||
expect(result).toBe(value);
|
||||
|
|
@ -271,7 +273,7 @@ describe('Graph Token Utilities', () => {
|
|||
|
||||
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
|
||||
await resolveGraphTokenPlaceholder(value, {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
graphTokenResolver: mockGraphTokenResolver,
|
||||
scopes: 'custom-scope',
|
||||
});
|
||||
|
|
@ -286,7 +288,7 @@ describe('Graph Token Utilities', () => {
|
|||
});
|
||||
|
||||
describe('resolveGraphTokensInRecord', () => {
|
||||
const mockUser: Partial<TUser> = {
|
||||
const mockUser: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
};
|
||||
|
|
@ -299,7 +301,7 @@ describe('Graph Token Utilities', () => {
|
|||
});
|
||||
|
||||
const options: GraphTokenOptions = {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
graphTokenResolver: mockGraphTokenResolver,
|
||||
};
|
||||
|
||||
|
|
@ -348,7 +350,7 @@ describe('Graph Token Utilities', () => {
|
|||
});
|
||||
|
||||
describe('preProcessGraphTokens', () => {
|
||||
const mockUser: Partial<TUser> = {
|
||||
const mockUser: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
};
|
||||
|
|
@ -361,7 +363,7 @@ describe('Graph Token Utilities', () => {
|
|||
});
|
||||
|
||||
const graphOptions: GraphTokenOptions = {
|
||||
user: mockUser as TUser,
|
||||
user: mockUser as Partial<IUser> as IUser,
|
||||
graphTokenResolver: mockGraphTokenResolver,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { extractOpenIDTokenInfo, isOpenIDTokenValid, processOpenIDPlaceholders } from './oidc';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
|
||||
describe('OpenID Token Utilities', () => {
|
||||
describe('extractOpenIDTokenInfo', () => {
|
||||
it('should extract token info from user with federatedTokens', () => {
|
||||
const user: Partial<TUser> = {
|
||||
const user: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
openidId: 'oidc-sub-456',
|
||||
|
|
@ -36,7 +36,7 @@ describe('OpenID Token Utilities', () => {
|
|||
});
|
||||
|
||||
it('should return null when user is not OpenID provider', () => {
|
||||
const user: Partial<TUser> = {
|
||||
const user: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'email',
|
||||
};
|
||||
|
|
@ -46,7 +46,7 @@ describe('OpenID Token Utilities', () => {
|
|||
});
|
||||
|
||||
it('should return token info when user has no federatedTokens but is OpenID provider', () => {
|
||||
const user: Partial<TUser> = {
|
||||
const user: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
openidId: 'oidc-sub-456',
|
||||
|
|
@ -66,7 +66,7 @@ describe('OpenID Token Utilities', () => {
|
|||
});
|
||||
|
||||
it('should extract partial token info when some tokens are missing', () => {
|
||||
const user: Partial<TUser> = {
|
||||
const user: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
openidId: 'oidc-sub-456',
|
||||
|
|
@ -89,7 +89,7 @@ describe('OpenID Token Utilities', () => {
|
|||
});
|
||||
|
||||
it('should prioritize openidId over regular id', () => {
|
||||
const user: Partial<TUser> = {
|
||||
const user: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
openidId: 'oidc-sub-456',
|
||||
|
|
@ -104,7 +104,7 @@ describe('OpenID Token Utilities', () => {
|
|||
});
|
||||
|
||||
it('should fall back to regular id when openidId is not available', () => {
|
||||
const user: Partial<TUser> = {
|
||||
const user: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
federatedTokens: {
|
||||
|
|
@ -397,7 +397,7 @@ describe('OpenID Token Utilities', () => {
|
|||
|
||||
describe('Integration: Full OpenID Token Flow', () => {
|
||||
it('should extract, validate, and process tokens correctly', () => {
|
||||
const user: Partial<TUser> = {
|
||||
const user: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
openidId: 'oidc-sub-456',
|
||||
|
|
@ -428,7 +428,7 @@ describe('OpenID Token Utilities', () => {
|
|||
});
|
||||
|
||||
it('should resolve LIBRECHAT_OPENID_ID_TOKEN and LIBRECHAT_OPENID_ACCESS_TOKEN to different values', () => {
|
||||
const user: Partial<TUser> = {
|
||||
const user: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
openidId: 'oidc-sub-456',
|
||||
|
|
@ -457,7 +457,7 @@ describe('OpenID Token Utilities', () => {
|
|||
});
|
||||
|
||||
it('should handle expired tokens correctly', () => {
|
||||
const user: Partial<TUser> = {
|
||||
const user: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
openidId: 'oidc-sub-456',
|
||||
|
|
@ -481,7 +481,7 @@ describe('OpenID Token Utilities', () => {
|
|||
});
|
||||
|
||||
it('should handle user with no federatedTokens but still has OpenID provider', () => {
|
||||
const user: Partial<TUser> = {
|
||||
const user: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'openid',
|
||||
openidId: 'oidc-sub-456',
|
||||
|
|
@ -499,7 +499,7 @@ describe('OpenID Token Utilities', () => {
|
|||
});
|
||||
|
||||
it('should handle non-OpenID users', () => {
|
||||
const user: Partial<TUser> = {
|
||||
const user: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'email',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import type { IUser, OIDCTokens } from '@librechat/data-schemas';
|
||||
|
||||
export interface OpenIDTokenInfo {
|
||||
accessToken?: string;
|
||||
|
|
@ -11,14 +11,7 @@ export interface OpenIDTokenInfo {
|
|||
claims?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FederatedTokens {
|
||||
access_token?: string;
|
||||
id_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_at?: number;
|
||||
}
|
||||
|
||||
function isFederatedTokens(obj: unknown): obj is FederatedTokens {
|
||||
function isFederatedTokens(obj: unknown): obj is OIDCTokens {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -61,23 +54,24 @@ export function extractOpenIDTokenInfo(
|
|||
|
||||
const tokenInfo: OpenIDTokenInfo = {};
|
||||
|
||||
if ('federatedTokens' in user && isFederatedTokens(user.federatedTokens)) {
|
||||
const tokens = user.federatedTokens;
|
||||
const federated = user.federatedTokens;
|
||||
const openid = user.openidTokens;
|
||||
|
||||
if (federated && isFederatedTokens(federated)) {
|
||||
logger.debug('[extractOpenIDTokenInfo] Found federatedTokens:', {
|
||||
has_access_token: !!tokens.access_token,
|
||||
has_id_token: !!tokens.id_token,
|
||||
has_refresh_token: !!tokens.refresh_token,
|
||||
expires_at: tokens.expires_at,
|
||||
has_access_token: !!federated.access_token,
|
||||
has_id_token: !!federated.id_token,
|
||||
has_refresh_token: !!federated.refresh_token,
|
||||
expires_at: federated.expires_at,
|
||||
});
|
||||
tokenInfo.accessToken = tokens.access_token;
|
||||
tokenInfo.idToken = tokens.id_token;
|
||||
tokenInfo.expiresAt = tokens.expires_at;
|
||||
} else if ('openidTokens' in user && isFederatedTokens(user.openidTokens)) {
|
||||
const tokens = user.openidTokens;
|
||||
tokenInfo.accessToken = federated.access_token;
|
||||
tokenInfo.idToken = federated.id_token;
|
||||
tokenInfo.expiresAt = federated.expires_at;
|
||||
} else if (openid && isFederatedTokens(openid)) {
|
||||
logger.debug('[extractOpenIDTokenInfo] Found openidTokens');
|
||||
tokenInfo.accessToken = tokens.access_token;
|
||||
tokenInfo.idToken = tokens.id_token;
|
||||
tokenInfo.expiresAt = tokens.expires_at;
|
||||
tokenInfo.accessToken = openid.access_token;
|
||||
tokenInfo.idToken = openid.id_token;
|
||||
tokenInfo.expiresAt = openid.expires_at;
|
||||
}
|
||||
|
||||
tokenInfo.userId = user.openidId || user.id;
|
||||
|
|
|
|||
4
packages/api/types/index.d.ts
vendored
Normal file
4
packages/api/types/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** String.prototype.isWellFormed — ES2024 API, available in Node 20+ but absent from TS 5.3 lib */
|
||||
interface String {
|
||||
isWellFormed(): boolean;
|
||||
}
|
||||
|
|
@ -80,9 +80,8 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDi
|
|||
showCancelButton = true,
|
||||
} = props;
|
||||
const isLegacySelection = isSelectionProps(selection);
|
||||
const { selectHandler, selectClasses, selectText, isLoading } = isLegacySelection
|
||||
? selection
|
||||
: {};
|
||||
const legacySelection = isLegacySelection ? selection : null;
|
||||
const { selectHandler, selectClasses, selectText, isLoading } = legacySelection ?? {};
|
||||
|
||||
const defaultSelect =
|
||||
'bg-gray-800 text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-gray-200';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import type { TFile } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
|
||||
export default function FileIcon({
|
||||
file,
|
||||
fileType,
|
||||
}: {
|
||||
file?: Partial<ExtendedFile | TFile>;
|
||||
file?: Partial<TFile> & { progress?: number };
|
||||
fileType: {
|
||||
fill: string;
|
||||
paths: React.FC;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
DeleteResult,
|
||||
Model,
|
||||
} from 'mongoose';
|
||||
import type { IAclEntry } from '~/types';
|
||||
import type { AclEntry, IAclEntry } from '~/types';
|
||||
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
|
||||
|
||||
export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
|
||||
|
|
@ -375,11 +375,11 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
|
|||
* @param options - Optional query options (e.g., { session })
|
||||
*/
|
||||
async function bulkWriteAclEntries(
|
||||
ops: AnyBulkWriteOperation<IAclEntry>[],
|
||||
ops: AnyBulkWriteOperation<AclEntry>[],
|
||||
options?: { session?: ClientSession },
|
||||
) {
|
||||
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
|
||||
return tenantSafeBulkWrite(AclEntry, ops, options || {});
|
||||
return tenantSafeBulkWrite(AclEntry, ops as AnyBulkWriteOperation[], options || {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ describe('upsertConfig', () => {
|
|||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ a: 1 },
|
||||
{ interface: { endpointsMenu: true } },
|
||||
10,
|
||||
);
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ describe('upsertConfig', () => {
|
|||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ a: 2 },
|
||||
{ interface: { endpointsMenu: false } },
|
||||
10,
|
||||
);
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ describe('upsertConfig', () => {
|
|||
|
||||
it('normalizes ObjectId principalId to string', async () => {
|
||||
const oid = new Types.ObjectId();
|
||||
await methods.upsertConfig(PrincipalType.USER, oid, PrincipalModel.USER, { test: true }, 100);
|
||||
await methods.upsertConfig(PrincipalType.USER, oid, PrincipalModel.USER, { cache: true }, 100);
|
||||
|
||||
const found = await methods.findConfigByPrincipal(PrincipalType.USER, oid.toString());
|
||||
expect(found).toBeTruthy();
|
||||
|
|
@ -98,7 +98,13 @@ describe('upsertConfig', () => {
|
|||
|
||||
describe('findConfigByPrincipal', () => {
|
||||
it('finds an active config', async () => {
|
||||
await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, { x: 1 }, 10);
|
||||
await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ cache: true },
|
||||
10,
|
||||
);
|
||||
|
||||
const result = await methods.findConfigByPrincipal(PrincipalType.ROLE, 'admin');
|
||||
expect(result).toBeTruthy();
|
||||
|
|
@ -111,7 +117,13 @@ describe('findConfigByPrincipal', () => {
|
|||
});
|
||||
|
||||
it('does not find inactive configs', async () => {
|
||||
await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, { x: 1 }, 10);
|
||||
await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ cache: true },
|
||||
10,
|
||||
);
|
||||
await methods.toggleConfigActive(PrincipalType.ROLE, 'admin', false);
|
||||
|
||||
const result = await methods.findConfigByPrincipal(PrincipalType.ROLE, 'admin');
|
||||
|
|
@ -155,7 +167,13 @@ describe('listAllConfigs', () => {
|
|||
|
||||
describe('getApplicableConfigs', () => {
|
||||
it('always includes the __base__ config', async () => {
|
||||
await methods.upsertConfig(PrincipalType.ROLE, '__base__', PrincipalModel.ROLE, { a: 1 }, 0);
|
||||
await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'__base__',
|
||||
PrincipalModel.ROLE,
|
||||
{ cache: true },
|
||||
0,
|
||||
);
|
||||
|
||||
const configs = await methods.getApplicableConfigs([]);
|
||||
expect(configs).toHaveLength(1);
|
||||
|
|
@ -163,9 +181,27 @@ describe('getApplicableConfigs', () => {
|
|||
});
|
||||
|
||||
it('returns base + matching principals', async () => {
|
||||
await methods.upsertConfig(PrincipalType.ROLE, '__base__', PrincipalModel.ROLE, { a: 1 }, 0);
|
||||
await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, { b: 2 }, 10);
|
||||
await methods.upsertConfig(PrincipalType.ROLE, 'user', PrincipalModel.ROLE, { c: 3 }, 10);
|
||||
await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'__base__',
|
||||
PrincipalModel.ROLE,
|
||||
{ cache: true },
|
||||
0,
|
||||
);
|
||||
await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ version: '2' },
|
||||
10,
|
||||
);
|
||||
await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'user',
|
||||
PrincipalModel.ROLE,
|
||||
{ version: '3' },
|
||||
10,
|
||||
);
|
||||
|
||||
const configs = await methods.getApplicableConfigs([
|
||||
{ principalType: PrincipalType.ROLE, principalId: 'admin' },
|
||||
|
|
|
|||
|
|
@ -285,12 +285,12 @@ describe('updateAccessPermissions', () => {
|
|||
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||
|
||||
// SHARED_GLOBAL=true → SHARE=true (inherited)
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]!.SHARE).toBe(true);
|
||||
// SHARED_GLOBAL=false → SHARE=false (inherited)
|
||||
expect(updatedRole.permissions[PermissionTypes.AGENTS].SHARE).toBe(false);
|
||||
expect(updatedRole.permissions[PermissionTypes.AGENTS]!.SHARE).toBe(false);
|
||||
// SHARED_GLOBAL cleaned up
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
|
||||
expect(updatedRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeUndefined();
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).not.toHaveProperty('SHARED_GLOBAL');
|
||||
expect(updatedRole.permissions[PermissionTypes.AGENTS]).not.toHaveProperty('SHARED_GLOBAL');
|
||||
});
|
||||
|
||||
it('should respect explicit SHARE in update payload and not override it with SHARED_GLOBAL', async () => {
|
||||
|
|
@ -309,8 +309,8 @@ describe('updateAccessPermissions', () => {
|
|||
|
||||
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(false);
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]!.SHARE).toBe(false);
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).not.toHaveProperty('SHARED_GLOBAL');
|
||||
});
|
||||
|
||||
it('should migrate SHARED_GLOBAL to SHARE even when the permType is not in the update payload', async () => {
|
||||
|
|
@ -336,13 +336,13 @@ describe('updateAccessPermissions', () => {
|
|||
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||
|
||||
// SHARE should have been inherited from SHARED_GLOBAL, not silently dropped
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]!.SHARE).toBe(true);
|
||||
// SHARED_GLOBAL should be removed
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).not.toHaveProperty('SHARED_GLOBAL');
|
||||
// Original USE should be untouched
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS].USE).toBe(true);
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]!.USE).toBe(true);
|
||||
// The actual update should have applied
|
||||
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(true);
|
||||
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]!.USE).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove orphaned SHARED_GLOBAL when SHARE already exists and permType is not in update', async () => {
|
||||
|
|
@ -366,9 +366,9 @@ describe('updateAccessPermissions', () => {
|
|||
|
||||
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
|
||||
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(true);
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).not.toHaveProperty('SHARED_GLOBAL');
|
||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]!.SHARE).toBe(true);
|
||||
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]!.USE).toBe(true);
|
||||
});
|
||||
|
||||
it('should not update MULTI_CONVO permissions when no changes are needed', async () => {
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
|
|||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Pick<IRole, '_id' | 'name' | 'description'>[]> {
|
||||
const Role = mongoose.models.Role;
|
||||
const Role = mongoose.models.Role as Model<IRole>;
|
||||
const limit = options?.limit ?? 50;
|
||||
const offset = options?.offset ?? 0;
|
||||
return await Role.find({})
|
||||
|
|
|
|||
|
|
@ -864,8 +864,8 @@ describe('spendTokens', () => {
|
|||
const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.prompt.prompt).toBeCloseTo(-expectedPromptCost, 0);
|
||||
expect(result!.completion.completion).toBeCloseTo(-expectedCompletionCost, 0);
|
||||
expect(result!.prompt!.prompt).toBeCloseTo(-expectedPromptCost, 0);
|
||||
expect(result!.completion!.completion).toBeCloseTo(-expectedCompletionCost, 0);
|
||||
});
|
||||
|
||||
it('should charge standard rates for structured tokens when below threshold', async () => {
|
||||
|
|
@ -907,8 +907,8 @@ describe('spendTokens', () => {
|
|||
const expectedCompletionCost = tokenUsage.completionTokens * standardCompletionRate;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.prompt.prompt).toBeCloseTo(-expectedPromptCost, 0);
|
||||
expect(result!.completion.completion).toBeCloseTo(-expectedCompletionCost, 0);
|
||||
expect(result!.prompt!.prompt).toBeCloseTo(-expectedPromptCost, 0);
|
||||
expect(result!.completion!.completion).toBeCloseTo(-expectedCompletionCost, 0);
|
||||
});
|
||||
|
||||
it('should charge standard rates for gemini-3.1-pro-preview when prompt tokens are below threshold', async () => {
|
||||
|
|
@ -937,7 +937,7 @@ describe('spendTokens', () => {
|
|||
completionTokens * tokenValues['gemini-3.1'].completion;
|
||||
|
||||
const balance = await Balance.findOne({ user: userId });
|
||||
expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
||||
expect(balance!.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
||||
});
|
||||
|
||||
it('should charge premium rates for gemini-3.1-pro-preview when prompt tokens exceed threshold', async () => {
|
||||
|
|
@ -966,7 +966,7 @@ describe('spendTokens', () => {
|
|||
completionTokens * premiumTokenValues['gemini-3.1'].completion;
|
||||
|
||||
const balance = await Balance.findOne({ user: userId });
|
||||
expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
||||
expect(balance!.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
||||
});
|
||||
|
||||
it('should charge premium rates for gemini-3.1-pro-preview-customtools when prompt tokens exceed threshold', async () => {
|
||||
|
|
@ -995,7 +995,7 @@ describe('spendTokens', () => {
|
|||
completionTokens * premiumTokenValues['gemini-3.1'].completion;
|
||||
|
||||
const balance = await Balance.findOne({ user: userId });
|
||||
expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
||||
expect(balance!.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
||||
});
|
||||
|
||||
it('should charge premium rates for structured gemini-3.1 tokens when total input exceeds threshold', async () => {
|
||||
|
|
@ -1032,13 +1032,13 @@ describe('spendTokens', () => {
|
|||
|
||||
const expectedPromptCost =
|
||||
tokenUsage.promptTokens.input * premiumPromptRate +
|
||||
tokenUsage.promptTokens.write * writeRate +
|
||||
tokenUsage.promptTokens.read * readRate;
|
||||
tokenUsage.promptTokens.write * writeRate! +
|
||||
tokenUsage.promptTokens.read * readRate!;
|
||||
const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.prompt.prompt).toBeCloseTo(-expectedPromptCost, 0);
|
||||
expect(result!.completion.completion).toBeCloseTo(-expectedCompletionCost, 0);
|
||||
expect(result!.prompt!.prompt).toBeCloseTo(-expectedPromptCost, 0);
|
||||
expect(result!.completion!.completion).toBeCloseTo(-expectedCompletionCost, 0);
|
||||
});
|
||||
|
||||
it('should not apply premium pricing to non-premium models regardless of prompt size', async () => {
|
||||
|
|
|
|||
|
|
@ -387,7 +387,7 @@ export function createTxMethods(_mongoose: typeof import('mongoose'), txDeps: Tx
|
|||
function getPremiumRate(
|
||||
valueKey: string,
|
||||
tokenType: string,
|
||||
inputTokenCount?: number,
|
||||
inputTokenCount?: number | null,
|
||||
): number | null {
|
||||
if (inputTokenCount == null) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ describe('User Methods', () => {
|
|||
|
||||
describe('generateToken', () => {
|
||||
const mockUser = {
|
||||
_id: 'user123',
|
||||
_id: new mongoose.Types.ObjectId('aaaaaaaaaaaaaaaaaaaaaaaa'),
|
||||
username: 'testuser',
|
||||
provider: 'local',
|
||||
email: 'test@example.com',
|
||||
|
|
|
|||
|
|
@ -35,26 +35,26 @@ export function createUserMethods(mongoose: typeof import('mongoose')) {
|
|||
searchCriteria: FilterQuery<IUser>,
|
||||
fieldsToSelect?: string | string[] | null,
|
||||
): Promise<IUser | null> {
|
||||
const User = mongoose.models.User;
|
||||
const User = mongoose.models.User as mongoose.Model<IUser>;
|
||||
const normalizedCriteria = normalizeEmailInCriteria(searchCriteria);
|
||||
const query = User.findOne(normalizedCriteria);
|
||||
if (fieldsToSelect) {
|
||||
query.select(fieldsToSelect);
|
||||
}
|
||||
return (await query.lean()) as IUser | null;
|
||||
return await query.lean();
|
||||
}
|
||||
|
||||
async function findUsers(
|
||||
searchCriteria: FilterQuery<IUser>,
|
||||
fieldsToSelect?: string | string[] | null,
|
||||
): Promise<IUser[]> {
|
||||
const User = mongoose.models.User;
|
||||
const User = mongoose.models.User as mongoose.Model<IUser>;
|
||||
const normalizedCriteria = normalizeEmailInCriteria(searchCriteria);
|
||||
const query = User.find(normalizedCriteria);
|
||||
if (fieldsToSelect) {
|
||||
query.select(fieldsToSelect);
|
||||
}
|
||||
return (await query.lean()) as IUser[];
|
||||
return await query.lean();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -301,8 +301,6 @@ export function createUserMethods(mongoose: typeof import('mongoose')) {
|
|||
.sort((a, b) => b._searchScore - a._searchScore)
|
||||
.slice(0, limit)
|
||||
.map((user) => {
|
||||
// Remove the search score from final results
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { _searchScore, ...userWithoutScore } = user;
|
||||
return userWithoutScore;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -496,7 +496,7 @@ describe('userGroup methods', () => {
|
|||
it('returns the updated user document', async () => {
|
||||
const user = await createTestUser({ idOnTheSource: 'user-ext-1' });
|
||||
const { user: updatedUser } = await methods.syncUserEntraGroups(user._id, []);
|
||||
expect(updatedUser._id.toString()).toBe(user._id.toString());
|
||||
expect((updatedUser._id as Types.ObjectId).toString()).toBe(user._id.toString());
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -265,13 +265,14 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
|
|||
role?: string | null;
|
||||
},
|
||||
session?: ClientSession,
|
||||
): Promise<Array<{ principalType: string; principalId?: string | Types.ObjectId }>> {
|
||||
): Promise<Array<{ principalType: PrincipalType; principalId?: string | Types.ObjectId }>> {
|
||||
const { userId, role } = params;
|
||||
/** `userId` must be an `ObjectId` for USER principal since ACL entries store `ObjectId`s */
|
||||
const userObjectId = typeof userId === 'string' ? new Types.ObjectId(userId) : userId;
|
||||
const principals: Array<{ principalType: string; principalId?: string | Types.ObjectId }> = [
|
||||
{ principalType: PrincipalType.USER, principalId: userObjectId },
|
||||
];
|
||||
const principals: Array<{
|
||||
principalType: PrincipalType;
|
||||
principalId?: string | Types.ObjectId;
|
||||
}> = [{ principalType: PrincipalType.USER, principalId: userObjectId }];
|
||||
|
||||
// If role is not provided, query user to get it
|
||||
let userRole = role;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export async function dropSupersededPromptGroupIndexes(
|
|||
|
||||
let collection;
|
||||
try {
|
||||
collection = connection.db.collection(collectionName);
|
||||
collection = connection.db!.collection(collectionName);
|
||||
} catch {
|
||||
result.skipped.push(
|
||||
...SUPERSEDED_PROMPT_GROUP_INDEXES.map(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import mongoose, { Schema } from 'mongoose';
|
||||
import mongoose from 'mongoose';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { dropSupersededTenantIndexes, SUPERSEDED_INDEXES } from './tenantIndexes';
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ afterAll(async () => {
|
|||
describe('dropSupersededTenantIndexes', () => {
|
||||
describe('with pre-existing single-field unique indexes (simulates upgrade)', () => {
|
||||
beforeAll(async () => {
|
||||
const db = mongoose.connection.db;
|
||||
const db = mongoose.connection.db!;
|
||||
|
||||
await db.createCollection('users');
|
||||
const users = db.collection('users');
|
||||
|
|
@ -133,7 +133,7 @@ describe('dropSupersededTenantIndexes', () => {
|
|||
});
|
||||
|
||||
it('old unique indexes are actually gone from users collection', async () => {
|
||||
const indexes = await mongoose.connection.db.collection('users').indexes();
|
||||
const indexes = await mongoose.connection.db!.collection('users').indexes();
|
||||
const indexNames = indexes.map((idx) => idx.name);
|
||||
|
||||
expect(indexNames).not.toContain('email_1');
|
||||
|
|
@ -143,14 +143,14 @@ describe('dropSupersededTenantIndexes', () => {
|
|||
});
|
||||
|
||||
it('old unique indexes are actually gone from roles collection', async () => {
|
||||
const indexes = await mongoose.connection.db.collection('roles').indexes();
|
||||
const indexes = await mongoose.connection.db!.collection('roles').indexes();
|
||||
const indexNames = indexes.map((idx) => idx.name);
|
||||
|
||||
expect(indexNames).not.toContain('name_1');
|
||||
});
|
||||
|
||||
it('old compound unique indexes are gone from conversations collection', async () => {
|
||||
const indexes = await mongoose.connection.db.collection('conversations').indexes();
|
||||
const indexes = await mongoose.connection.db!.collection('conversations').indexes();
|
||||
const indexNames = indexes.map((idx) => idx.name);
|
||||
|
||||
expect(indexNames).not.toContain('conversationId_1_user_1');
|
||||
|
|
@ -159,7 +159,7 @@ describe('dropSupersededTenantIndexes', () => {
|
|||
|
||||
describe('multi-tenant writes after migration', () => {
|
||||
beforeAll(async () => {
|
||||
const db = mongoose.connection.db;
|
||||
const db = mongoose.connection.db!;
|
||||
|
||||
const users = db.collection('users');
|
||||
await users.createIndex(
|
||||
|
|
@ -169,7 +169,7 @@ describe('dropSupersededTenantIndexes', () => {
|
|||
});
|
||||
|
||||
it('allows same email in different tenants after old index is dropped', async () => {
|
||||
const users = mongoose.connection.db.collection('users');
|
||||
const users = mongoose.connection.db!.collection('users');
|
||||
|
||||
await users.insertOne({
|
||||
email: 'shared@example.com',
|
||||
|
|
@ -196,7 +196,7 @@ describe('dropSupersededTenantIndexes', () => {
|
|||
});
|
||||
|
||||
it('still rejects duplicate email within same tenant', async () => {
|
||||
const users = mongoose.connection.db.collection('users');
|
||||
const users = mongoose.connection.db!.collection('users');
|
||||
|
||||
await users.insertOne({
|
||||
email: 'unique-within@example.com',
|
||||
|
|
@ -247,7 +247,7 @@ describe('dropSupersededTenantIndexes', () => {
|
|||
partialConnection = mongoose.createConnection(partialServer.getUri());
|
||||
await partialConnection.asPromise();
|
||||
|
||||
const db = partialConnection.db;
|
||||
const db = partialConnection.db!;
|
||||
await db.createCollection('users');
|
||||
await db.collection('users').createIndex({ email: 1 }, { unique: true, name: 'email_1' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export async function dropSupersededTenantIndexes(
|
|||
const result: MigrationResult = { dropped: [], skipped: [], errors: [] };
|
||||
|
||||
for (const [collectionName, indexNames] of Object.entries(SUPERSEDED_INDEXES)) {
|
||||
const collection = connection.db.collection(collectionName);
|
||||
const collection = connection.db!.collection(collectionName);
|
||||
|
||||
let existingIndexes: Array<{ name?: string }>;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
import type {
|
||||
PrincipalType,
|
||||
PrincipalModel,
|
||||
TCustomConfig,
|
||||
z,
|
||||
configSchema,
|
||||
} from 'librechat-data-provider';
|
||||
import type { PrincipalType, PrincipalModel, TCustomConfig } from 'librechat-data-provider';
|
||||
import type { SystemCapabilities } from '~/admin/capabilities';
|
||||
|
||||
/* ── Capability types ───────────────────────────────────────────────── */
|
||||
|
|
@ -16,7 +10,7 @@ export type BaseSystemCapability = (typeof SystemCapabilities)[keyof typeof Syst
|
|||
export type ConfigAssignTarget = 'user' | 'group' | 'role';
|
||||
|
||||
/** Top-level keys of the configSchema from librechat.yaml. */
|
||||
export type ConfigSection = keyof z.infer<typeof configSchema>;
|
||||
export type ConfigSection = string & keyof TCustomConfig;
|
||||
|
||||
/** Section-level config capabilities derived from configSchema keys. */
|
||||
type ConfigSectionCapability = `manage:configs:${ConfigSection}` | `read:configs:${ConfigSection}`;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Document, Types } from 'mongoose';
|
|||
import { CursorPaginationParams } from '~/common';
|
||||
|
||||
export interface IUser extends Document {
|
||||
_id: Types.ObjectId;
|
||||
name?: string;
|
||||
username?: string;
|
||||
email: string;
|
||||
|
|
@ -50,6 +51,15 @@ export interface IUser extends Document {
|
|||
/** Field for external source identification (for consistency with TPrincipal schema) */
|
||||
idOnTheSource?: string;
|
||||
tenantId?: string;
|
||||
federatedTokens?: OIDCTokens;
|
||||
openidTokens?: OIDCTokens;
|
||||
}
|
||||
|
||||
export interface OIDCTokens {
|
||||
access_token?: string;
|
||||
id_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_at?: number;
|
||||
}
|
||||
|
||||
export interface BalanceConfig {
|
||||
|
|
|
|||
34
packages/data-schemas/src/types/winston-transports.d.ts
vendored
Normal file
34
packages/data-schemas/src/types/winston-transports.d.ts
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type TransportStream from 'winston-transport';
|
||||
|
||||
/**
|
||||
* Module augmentation for winston's transports namespace.
|
||||
*
|
||||
* `winston-daily-rotate-file` ships its own augmentation targeting
|
||||
* `'winston/lib/winston/transports'`, but it fails when winston and
|
||||
* winston-daily-rotate-file resolve from different node_modules trees
|
||||
* (which happens in this monorepo due to npm hoisting). This local
|
||||
* declaration bridges the gap so `tsc --noEmit` passes.
|
||||
*/
|
||||
declare module 'winston/lib/winston/transports' {
|
||||
interface Transports {
|
||||
DailyRotateFile: new (
|
||||
opts?: {
|
||||
level?: string;
|
||||
filename?: string;
|
||||
datePattern?: string;
|
||||
zippedArchive?: boolean;
|
||||
maxSize?: string | number;
|
||||
maxFiles?: string | number;
|
||||
dirname?: string;
|
||||
stream?: NodeJS.WritableStream;
|
||||
frequency?: string;
|
||||
utc?: boolean;
|
||||
extension?: string;
|
||||
createSymlink?: boolean;
|
||||
symlinkName?: string;
|
||||
auditFile?: string;
|
||||
format?: import('logform').Format;
|
||||
} & TransportStream.TransportStreamOptions,
|
||||
) => TransportStream;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue