From fda1bfc3ccf2a7e0d0abecdd80c28a72a84be230 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 28 Mar 2026 21:06:39 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=AC=20ci:=20Add=20TypeScript=20Type=20?= =?UTF-8?q?Checks=20to=20Backend=20Workflow=20and=20Fix=20All=20Type=20Err?= =?UTF-8?q?ors=20(#12451)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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` and `Model` - 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[]` 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` TestConfig with named interface containing explicit test fields (Finding 7) --- .github/workflows/backend-review.yml | 59 +++++++++++++++++++ packages/api/src/admin/config.handler.spec.ts | 25 +++++--- packages/api/src/app/service.spec.ts | 22 +++++-- packages/api/src/auth/openid.spec.ts | 53 ++++++++--------- .../sessionCache.cache_integration.spec.ts | 49 +++++++-------- .../redisClients.cache_integration.spec.ts | 24 +++++--- .../src/endpoints/custom/initialize.spec.ts | 4 +- .../src/mcp/__tests__/MCPOAuthFlow.test.ts | 9 +-- .../mcp/__tests__/MCPOAuthSecurity.test.ts | 27 +++++---- .../__tests__/MCPOAuthTokenStorage.test.ts | 4 +- ...gsCacheRedis.perf_benchmark.manual.spec.ts | 13 +++- ...edisAggregateKey.cache_integration.spec.ts | 4 +- packages/api/src/middleware/capabilities.ts | 7 ++- .../api/src/middleware/preAuthTenant.spec.ts | 2 +- packages/api/src/types/es2024-string.d.ts | 4 ++ packages/api/src/utils/env.ts | 4 +- packages/api/src/utils/graph.spec.ts | 42 ++++++------- packages/api/src/utils/oidc.spec.ts | 24 ++++---- packages/api/src/utils/oidc.ts | 40 ++++++------- packages/api/types/index.d.ts | 4 ++ .../src/components/OGDialogTemplate.tsx | 5 +- packages/client/src/svgs/FileIcon.tsx | 3 +- packages/data-schemas/src/methods/aclEntry.ts | 6 +- .../data-schemas/src/methods/config.spec.ts | 54 ++++++++++++++--- .../src/methods/role.methods.spec.ts | 26 ++++---- packages/data-schemas/src/methods/role.ts | 2 +- .../src/methods/spendTokens.spec.ts | 22 +++---- packages/data-schemas/src/methods/tx.ts | 2 +- .../data-schemas/src/methods/user.test.ts | 2 +- packages/data-schemas/src/methods/user.ts | 10 ++-- .../src/methods/userGroup.spec.ts | 2 +- .../data-schemas/src/methods/userGroup.ts | 9 +-- .../src/migrations/promptGroupIndexes.ts | 2 +- .../src/migrations/tenantIndexes.spec.ts | 18 +++--- .../src/migrations/tenantIndexes.ts | 2 +- packages/data-schemas/src/types/admin.ts | 10 +--- packages/data-schemas/src/types/user.ts | 10 ++++ .../src/types/winston-transports.d.ts | 34 +++++++++++ 38 files changed, 406 insertions(+), 233 deletions(-) create mode 100644 packages/api/src/types/es2024-string.d.ts create mode 100644 packages/api/types/index.d.ts create mode 100644 packages/data-schemas/src/types/winston-transports.d.ts diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 038c90627e..9dd3905c0e 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -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 diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index 705c54babc..708d114e72 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -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 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 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 } }); }); }); }); diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts index 4232a36dc3..c410783793 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -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 }; + }, _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).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).x).toBe('admin-only'); + expect((config as TestConfig).x).toBe('admin-only'); }); it('falls back to base config on getApplicableConfigs error', async () => { diff --git a/packages/api/src/auth/openid.spec.ts b/packages/api/src/auth/openid.spec.ts index 0761a24e85..2cf3992cdf 100644 --- a/packages/api/src/auth/openid.spec.ts +++ b/packages/api/src/auth/openid.spec.ts @@ -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', diff --git a/packages/api/src/cache/__tests__/cacheFactory/sessionCache.cache_integration.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/sessionCache.cache_integration.spec.ts index f4ded8bc74..99c0d69b37 100644 --- a/packages/api/src/cache/__tests__/cacheFactory/sessionCache.cache_integration.spec.ts +++ b/packages/api/src/cache/__tests__/cacheFactory/sessionCache.cache_integration.spec.ts @@ -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((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((resolve) => + store.set(id, data as Partial as SessionData, () => resolve()), + ), get: (id: string) => - new Promise((resolve) => - store.get(id, (_, data) => resolve(data)), + new Promise((resolve) => + store.get(id, (_, data) => resolve(data as TestSessionData | null | undefined)), ), destroy: (id: string) => new Promise((resolve) => store.destroy(id, () => resolve())), - touch: (id: string, data: SessionData) => - new Promise((resolve) => store.touch(id, data, () => resolve())), + touch: (id: string, data: TestSessionData) => + new Promise((resolve) => + store.touch(id, data as Partial 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 diff --git a/packages/api/src/cache/__tests__/redisClients.cache_integration.spec.ts b/packages/api/src/cache/__tests__/redisClients.cache_integration.spec.ts index dc9a325746..77e8c01436 100644 --- a/packages/api/src/cache/__tests__/redisClients.cache_integration.spec.ts +++ b/packages/api/src/cache/__tests__/redisClients.cache_integration.spec.ts @@ -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), + ); }); }); }); diff --git a/packages/api/src/endpoints/custom/initialize.spec.ts b/packages/api/src/endpoints/custom/initialize.spec.ts index 3705f98977..eddd7cb515 100644 --- a/packages/api/src/endpoints/custom/initialize.spec.ts +++ b/packages/api/src/endpoints/custom/initialize.spec.ts @@ -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); diff --git a/packages/api/src/mcp/__tests__/MCPOAuthFlow.test.ts b/packages/api/src/mcp/__tests__/MCPOAuthFlow.test.ts index 7e26165cad..cbd29d3571 100644 --- a/packages/api/src/mcp/__tests__/MCPOAuthFlow.test.ts +++ b/packages/api/src/mcp/__tests__/MCPOAuthFlow.test.ts @@ -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 () => { diff --git a/packages/api/src/mcp/__tests__/MCPOAuthSecurity.test.ts b/packages/api/src/mcp/__tests__/MCPOAuthSecurity.test.ts index a2d0440d42..d50e29eab7 100644 --- a/packages/api/src/mcp/__tests__/MCPOAuthSecurity.test.ts +++ b/packages/api/src/mcp/__tests__/MCPOAuthSecurity.test.ts @@ -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; diff --git a/packages/api/src/mcp/__tests__/MCPOAuthTokenStorage.test.ts b/packages/api/src/mcp/__tests__/MCPOAuthTokenStorage.test.ts index 3805586453..2d3905d2fb 100644 --- a/packages/api/src/mcp/__tests__/MCPOAuthTokenStorage.test.ts +++ b/packages/api/src/mcp/__tests__/MCPOAuthTokenStorage.test.ts @@ -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({ diff --git a/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheRedis.perf_benchmark.manual.spec.ts b/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheRedis.perf_benchmark.manual.spec.ts index 1815d49fe0..d9dc7bb978 100644 --- a/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheRedis.perf_benchmark.manual.spec.ts +++ b/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheRedis.perf_benchmark.manual.spec.ts @@ -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); } diff --git a/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheRedisAggregateKey.cache_integration.spec.ts b/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheRedisAggregateKey.cache_integration.spec.ts index 5aeb49b206..4ec30187a2 100644 --- a/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheRedisAggregateKey.cache_integration.spec.ts +++ b/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheRedisAggregateKey.cache_integration.spec.ts @@ -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(); }); diff --git a/packages/api/src/middleware/capabilities.ts b/packages/api/src/middleware/capabilities.ts index 28d3a0f76e..a3f1fe9038 100644 --- a/packages/api/src/middleware/capabilities.ts +++ b/packages/api/src/middleware/capabilities.ts @@ -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; + getUserPrincipals: ( + params: { userId: string | Types.ObjectId; role?: string | null }, + session?: ClientSession, + ) => Promise; hasCapabilityForPrincipals: (params: { principals: ResolvedPrincipal[]; capability: SystemCapability; diff --git a/packages/api/src/middleware/preAuthTenant.spec.ts b/packages/api/src/middleware/preAuthTenant.spec.ts index ed35da2324..669a43c84f 100644 --- a/packages/api/src/middleware/preAuthTenant.spec.ts +++ b/packages/api/src/middleware/preAuthTenant.spec.ts @@ -13,7 +13,7 @@ jest.mock('@librechat/data-schemas', () => ({ })); describe('preAuthTenantMiddleware', () => { - let req: Partial; + let req: { headers: Record; ip?: string; path?: string }; let res: Partial; beforeEach(() => { diff --git a/packages/api/src/types/es2024-string.d.ts b/packages/api/src/types/es2024-string.d.ts new file mode 100644 index 0000000000..f25bc46bda --- /dev/null +++ b/packages/api/src/types/es2024-string.d.ts @@ -0,0 +1,4 @@ +/** String.prototype.isWellFormed — ES2024 API, available in Node 20+ but absent from TS 5.3 lib */ +interface String { + isWellFormed(): boolean; +} diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index adeeb24b34..f71a131c09 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -84,12 +84,12 @@ export function encodeHeaderValue(value: string): string { */ export function createSafeUser( user: IUser | null | undefined, -): Partial & { federatedTokens?: unknown } { +): Partial & { federatedTokens?: IUser['federatedTokens'] } { if (!user) { return {}; } - const safeUser: Partial & { federatedTokens?: unknown } = {}; + const safeUser: Partial & { federatedTokens?: IUser['federatedTokens'] } = {}; for (const field of ALLOWED_USER_FIELDS) { if (field in user) { safeUser[field] = user[field]; diff --git a/packages/api/src/utils/graph.spec.ts b/packages/api/src/utils/graph.spec.ts index 4f1fa14983..91f8a29eff 100644 --- a/packages/api/src/utils/graph.spec.ts +++ b/packages/api/src/utils/graph.spec.ts @@ -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)).toBe( - false, - ); + expect( + recordContainsGraphTokenPlaceholder('string' as unknown as Record), + ).toBe(false); }); }); @@ -141,7 +141,7 @@ describe('Graph Token Utilities', () => { }); describe('resolveGraphTokenPlaceholder', () => { - const mockUser: Partial = { + const mockUser: Partial = { 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 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 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 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 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 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 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 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 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 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 as IUser, graphTokenResolver: mockGraphTokenResolver, scopes: 'custom-scope', }); @@ -286,7 +288,7 @@ describe('Graph Token Utilities', () => { }); describe('resolveGraphTokensInRecord', () => { - const mockUser: Partial = { + const mockUser: Partial = { id: 'user-123', provider: 'openid', }; @@ -299,7 +301,7 @@ describe('Graph Token Utilities', () => { }); const options: GraphTokenOptions = { - user: mockUser as TUser, + user: mockUser as Partial as IUser, graphTokenResolver: mockGraphTokenResolver, }; @@ -348,7 +350,7 @@ describe('Graph Token Utilities', () => { }); describe('preProcessGraphTokens', () => { - const mockUser: Partial = { + const mockUser: Partial = { id: 'user-123', provider: 'openid', }; @@ -361,7 +363,7 @@ describe('Graph Token Utilities', () => { }); const graphOptions: GraphTokenOptions = { - user: mockUser as TUser, + user: mockUser as Partial as IUser, graphTokenResolver: mockGraphTokenResolver, }; diff --git a/packages/api/src/utils/oidc.spec.ts b/packages/api/src/utils/oidc.spec.ts index 0d7216304b..e7088d9897 100644 --- a/packages/api/src/utils/oidc.spec.ts +++ b/packages/api/src/utils/oidc.spec.ts @@ -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 = { + const user: Partial = { 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 = { + const user: Partial = { 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 = { + const user: Partial = { 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 = { + const user: Partial = { 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 = { + const user: Partial = { 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 = { + const user: Partial = { 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 = { + const user: Partial = { 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 = { + const user: Partial = { 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 = { + const user: Partial = { 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 = { + const user: Partial = { 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 = { + const user: Partial = { id: 'user-123', provider: 'email', }; diff --git a/packages/api/src/utils/oidc.ts b/packages/api/src/utils/oidc.ts index dbf41818c4..51056406c1 100644 --- a/packages/api/src/utils/oidc.ts +++ b/packages/api/src/utils/oidc.ts @@ -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; } -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; diff --git a/packages/api/types/index.d.ts b/packages/api/types/index.d.ts new file mode 100644 index 0000000000..f25bc46bda --- /dev/null +++ b/packages/api/types/index.d.ts @@ -0,0 +1,4 @@ +/** String.prototype.isWellFormed — ES2024 API, available in Node 20+ but absent from TS 5.3 lib */ +interface String { + isWellFormed(): boolean; +} diff --git a/packages/client/src/components/OGDialogTemplate.tsx b/packages/client/src/components/OGDialogTemplate.tsx index 300ae5b194..2414915a4b 100644 --- a/packages/client/src/components/OGDialogTemplate.tsx +++ b/packages/client/src/components/OGDialogTemplate.tsx @@ -80,9 +80,8 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref; + file?: Partial & { progress?: number }; fileType: { fill: string; paths: React.FC; diff --git a/packages/data-schemas/src/methods/aclEntry.ts b/packages/data-schemas/src/methods/aclEntry.ts index 2f61861029..d93693641c 100644 --- a/packages/data-schemas/src/methods/aclEntry.ts +++ b/packages/data-schemas/src/methods/aclEntry.ts @@ -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[], + ops: AnyBulkWriteOperation[], options?: { session?: ClientSession }, ) { const AclEntry = mongoose.models.AclEntry as Model; - return tenantSafeBulkWrite(AclEntry, ops, options || {}); + return tenantSafeBulkWrite(AclEntry, ops as AnyBulkWriteOperation[], options || {}); } /** diff --git a/packages/data-schemas/src/methods/config.spec.ts b/packages/data-schemas/src/methods/config.spec.ts index 82f43c2b37..8bcf73a733 100644 --- a/packages/data-schemas/src/methods/config.spec.ts +++ b/packages/data-schemas/src/methods/config.spec.ts @@ -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' }, diff --git a/packages/data-schemas/src/methods/role.methods.spec.ts b/packages/data-schemas/src/methods/role.methods.spec.ts index f8a66bef5d..be75be7b6f 100644 --- a/packages/data-schemas/src/methods/role.methods.spec.ts +++ b/packages/data-schemas/src/methods/role.methods.spec.ts @@ -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 () => { diff --git a/packages/data-schemas/src/methods/role.ts b/packages/data-schemas/src/methods/role.ts index 442041dcde..e84b91420a 100644 --- a/packages/data-schemas/src/methods/role.ts +++ b/packages/data-schemas/src/methods/role.ts @@ -69,7 +69,7 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol limit?: number; offset?: number; }): Promise[]> { - const Role = mongoose.models.Role; + const Role = mongoose.models.Role as Model; const limit = options?.limit ?? 50; const offset = options?.offset ?? 0; return await Role.find({}) diff --git a/packages/data-schemas/src/methods/spendTokens.spec.ts b/packages/data-schemas/src/methods/spendTokens.spec.ts index 5730bc7bdd..d505663d57 100644 --- a/packages/data-schemas/src/methods/spendTokens.spec.ts +++ b/packages/data-schemas/src/methods/spendTokens.spec.ts @@ -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 () => { diff --git a/packages/data-schemas/src/methods/tx.ts b/packages/data-schemas/src/methods/tx.ts index a1be4190ba..a048874457 100644 --- a/packages/data-schemas/src/methods/tx.ts +++ b/packages/data-schemas/src/methods/tx.ts @@ -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; diff --git a/packages/data-schemas/src/methods/user.test.ts b/packages/data-schemas/src/methods/user.test.ts index 522e4fe158..5e557805e4 100644 --- a/packages/data-schemas/src/methods/user.test.ts +++ b/packages/data-schemas/src/methods/user.test.ts @@ -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', diff --git a/packages/data-schemas/src/methods/user.ts b/packages/data-schemas/src/methods/user.ts index 137c01d0cd..0b630e49b3 100644 --- a/packages/data-schemas/src/methods/user.ts +++ b/packages/data-schemas/src/methods/user.ts @@ -35,26 +35,26 @@ export function createUserMethods(mongoose: typeof import('mongoose')) { searchCriteria: FilterQuery, fieldsToSelect?: string | string[] | null, ): Promise { - const User = mongoose.models.User; + const User = mongoose.models.User as mongoose.Model; 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, fieldsToSelect?: string | string[] | null, ): Promise { - const User = mongoose.models.User; + const User = mongoose.models.User as mongoose.Model; 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; }); diff --git a/packages/data-schemas/src/methods/userGroup.spec.ts b/packages/data-schemas/src/methods/userGroup.spec.ts index 675fdb2592..ca83ced7d9 100644 --- a/packages/data-schemas/src/methods/userGroup.spec.ts +++ b/packages/data-schemas/src/methods/userGroup.spec.ts @@ -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()); }); }); diff --git a/packages/data-schemas/src/methods/userGroup.ts b/packages/data-schemas/src/methods/userGroup.ts index 948542e6de..0e6b57adb2 100644 --- a/packages/data-schemas/src/methods/userGroup.ts +++ b/packages/data-schemas/src/methods/userGroup.ts @@ -265,13 +265,14 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { role?: string | null; }, session?: ClientSession, - ): Promise> { + ): Promise> { 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; diff --git a/packages/data-schemas/src/migrations/promptGroupIndexes.ts b/packages/data-schemas/src/migrations/promptGroupIndexes.ts index 4b6013c9e4..2d389f3f09 100644 --- a/packages/data-schemas/src/migrations/promptGroupIndexes.ts +++ b/packages/data-schemas/src/migrations/promptGroupIndexes.ts @@ -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( diff --git a/packages/data-schemas/src/migrations/tenantIndexes.spec.ts b/packages/data-schemas/src/migrations/tenantIndexes.spec.ts index e62b587a6e..6a0987d757 100644 --- a/packages/data-schemas/src/migrations/tenantIndexes.spec.ts +++ b/packages/data-schemas/src/migrations/tenantIndexes.spec.ts @@ -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' }); }); diff --git a/packages/data-schemas/src/migrations/tenantIndexes.ts b/packages/data-schemas/src/migrations/tenantIndexes.ts index a8b4e51768..6536423ad2 100644 --- a/packages/data-schemas/src/migrations/tenantIndexes.ts +++ b/packages/data-schemas/src/migrations/tenantIndexes.ts @@ -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 { diff --git a/packages/data-schemas/src/types/admin.ts b/packages/data-schemas/src/types/admin.ts index 9b30cdb98a..a16f68ae9c 100644 --- a/packages/data-schemas/src/types/admin.ts +++ b/packages/data-schemas/src/types/admin.ts @@ -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; +export type ConfigSection = string & keyof TCustomConfig; /** Section-level config capabilities derived from configSchema keys. */ type ConfigSectionCapability = `manage:configs:${ConfigSection}` | `read:configs:${ConfigSection}`; diff --git a/packages/data-schemas/src/types/user.ts b/packages/data-schemas/src/types/user.ts index 0fac46ee63..2d8eb82f47 100644 --- a/packages/data-schemas/src/types/user.ts +++ b/packages/data-schemas/src/types/user.ts @@ -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 { diff --git a/packages/data-schemas/src/types/winston-transports.d.ts b/packages/data-schemas/src/types/winston-transports.d.ts new file mode 100644 index 0000000000..704486e5ce --- /dev/null +++ b/packages/data-schemas/src/types/winston-transports.d.ts @@ -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; + } +}