🔬 ci: Add TypeScript Type Checks to Backend Workflow and Fix All Type Errors (#12451)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

* 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:
Danny Avila 2026-03-28 21:06:39 -04:00 committed by GitHub
parent d5c7d9f525
commit fda1bfc3cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 406 additions and 233 deletions

View file

@ -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 } });
});
});
});

View file

@ -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 () => {

View file

@ -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',

View file

@ -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

View file

@ -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),
);
});
});
});

View file

@ -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);

View file

@ -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 () => {

View file

@ -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;

View file

@ -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({

View file

@ -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);
}

View file

@ -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();
});

View file

@ -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;

View file

@ -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(() => {

View 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;
}

View file

@ -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];

View file

@ -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,
};

View file

@ -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',
};

View file

@ -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;