LibreChat/packages/api/src/middleware/capabilities.ts
Danny Avila fda1bfc3cc
🔬 ci: Add TypeScript Type Checks to Backend Workflow and Fix All Type Errors (#12451)
* fix(data-schemas): resolve TypeScript strict type check errors in source files

- Constrain ConfigSection to string keys via `string & keyof TCustomConfig`
- Replace broken `z` import from data-provider with TCustomConfig derivation
- Add `_id: Types.ObjectId` to IUser matching other Document interfaces
- Add `federatedTokens` and `openidTokens` optional fields to IUser
- Type mongoose model accessors as `Model<IRole>` and `Model<IUser>`
- Widen `getPremiumRate` param to accept `number | null`
- Widen `bulkWriteAclEntries` ops to untyped `AnyBulkWriteOperation[]`
- Fix `getUserPrincipals` return type to use `PrincipalType` enum
- Add non-null assertions for `connection.db` in migration files
- Import DailyRotateFile constructor directly instead of relying on
  broken module augmentation across mismatched node_modules trees
- Add winston-daily-rotate-file as devDependency for type resolution

* fix(data-schemas): resolve TypeScript type errors in test files

- Replace arbitrary test keys with valid TCustomConfig properties in config.spec
- Use non-null assertions for permission objects in role.methods.spec
- Replace `.SHARED_GLOBAL` access with `.not.toHaveProperty()` for legacy field
- Add non-null assertions for balance, writeRate, readRate in spendTokens.spec
- Update mock user _id to use ObjectId in user.test
- Remove unused Schema import in tenantIndexes.spec

* fix(api): resolve TypeScript strict type check errors across source and test files

- Widen getUserPrincipals dep type in capabilities middleware
- Fix federatedTokens type in createSafeUser return
- Use proper mock req type for read-only properties in preAuthTenant.spec
- Replace `as IUser` casts with ObjectId-typed mocks in openid/oidc specs
- Use TokenExchangeMethodEnum values instead of string literals in MCP specs
- Fix SessionStore type compatibility in sessionCache specs
- Replace `catch (error: any)` with `(error as Error)` in redis specs
- Remove invalid properties from test data in initialize and MCP specs
- Add String.prototype.isWellFormed declaration for sanitizeTitle spec

* fix(client): resolve TypeScript type errors in shared client components

- Add default values for destructured bindings in OGDialogTemplate
- Replace broken ExtendedFile import with inline type in FileIcon

* ci: add TypeScript type-check job to backend review workflow

Add a `typecheck` job that runs `tsc --noEmit` on all four TypeScript
workspaces (data-provider, data-schemas, @librechat/api, @librechat/client)
after the build step. Catches type errors that rollup builds may miss.

* fix(data-schemas): add local type declaration for DailyRotateFile transport

The `winston-daily-rotate-file` package ships a module augmentation for
`winston/lib/winston/transports`, but it fails when winston and
winston-daily-rotate-file resolve from different node_modules trees
(which happens in this monorepo due to npm hoisting).

Add a local `.d.ts` declaration that augments the same module path from
within data-schemas' compilation unit, so `tsc --noEmit` passes while
keeping the original runtime pattern (`new winston.transports.DailyRotateFile`).

* fix: address code review findings from PR #12451

- Restore typed `AnyBulkWriteOperation<AclEntry>[]` on bulkWriteAclEntries,
  cast to untyped only at the tenantSafeBulkWrite call site (Finding 1)
- Type `findUser` model accessor consistently with `findUsers` (Finding 2)
- Replace inline `import('mongoose').ClientSession` with top-level import type
- Use `toHaveLength` for spy assertions in playwright-expect spec file
- Replace numbered Record casts with `.not.toHaveProperty()` in
  role.methods.spec for SHARED_GLOBAL assertions
- Use per-test ObjectIds instead of shared testUserId in openid.spec
- Replace inline `import()` type annotations with top-level SessionData
  import in sessionCache spec
- Remove extraneous blank line in user.ts searchUsers

* refactor: address remaining review findings (4–7)

- Extract OIDCTokens interface in user.ts; deduplicate across IUser fields
  and oidc.ts FederatedTokens (Finding 4)
- Move String.isWellFormed declaration from spec file to project-level
  src/types/es2024-string.d.ts (Finding 5)
- Replace verbose `= undefined` defaults in OGDialogTemplate with null
  coalescing pattern (Finding 6)
- Replace `Record<string, unknown>` TestConfig with named interface
  containing explicit test fields (Finding 7)
2026-03-28 21:06:39 -04:00

194 lines
6.1 KiB
TypeScript

import { isMainThread } from 'node:worker_threads';
import { AsyncLocalStorage } from 'node:async_hooks';
import {
logger,
configCapability,
SystemCapabilities,
readConfigCapability,
} from '@librechat/data-schemas';
import type { PrincipalType } from 'librechat-data-provider';
import type { SystemCapability, ConfigSection } from '@librechat/data-schemas';
import type { NextFunction, Response } from 'express';
import type { Types, ClientSession } from 'mongoose';
import type { ServerRequest } from '~/types/http';
interface ResolvedPrincipal {
principalType: PrincipalType;
principalId?: string | Types.ObjectId;
}
interface CapabilityDeps {
getUserPrincipals: (
params: { userId: string | Types.ObjectId; role?: string | null },
session?: ClientSession,
) => Promise<ResolvedPrincipal[]>;
hasCapabilityForPrincipals: (params: {
principals: ResolvedPrincipal[];
capability: SystemCapability;
tenantId?: string;
}) => Promise<boolean>;
}
export interface CapabilityUser {
id: string;
role: string;
tenantId?: string;
}
interface CapabilityStore {
principals: Map<string, ResolvedPrincipal[]>;
results: Map<string, boolean>;
}
export type HasCapabilityFn = (
user: CapabilityUser,
capability: SystemCapability,
) => Promise<boolean>;
export type RequireCapabilityFn = (
capability: SystemCapability,
) => (req: ServerRequest, res: Response, next: NextFunction) => Promise<void>;
export type HasConfigCapabilityFn = (
user: CapabilityUser,
section: ConfigSection | null,
verb?: 'manage' | 'read',
) => Promise<boolean>;
/**
* Per-request store for caching resolved principals and capability check results.
* When running inside an Express request (via `capabilityContextMiddleware`),
* duplicate `hasCapability` calls within the same request are served from
* the in-memory Map instead of hitting the database again.
* Outside a request context (background jobs, tests), the store is undefined
* and every check falls through to the database — correct behavior.
*/
export const capabilityStore = new AsyncLocalStorage<CapabilityStore>();
export function capabilityContextMiddleware(
_req: ServerRequest,
_res: Response,
next: NextFunction,
): void {
if (!isMainThread) {
logger.error(
'[capabilityContextMiddleware] Mounted in a worker thread — ' +
'ALS context will not propagate to the main thread or other workers. ' +
'This middleware should only run in the main Express process.',
);
}
capabilityStore.run({ principals: new Map(), results: new Map() }, next);
}
/**
* Factory that creates `hasCapability` and `requireCapability` with injected
* database methods. Follows the same dependency-injection pattern as
* `generateCheckAccess`.
*/
export function generateCapabilityCheck(deps: CapabilityDeps): {
hasCapability: HasCapabilityFn;
requireCapability: RequireCapabilityFn;
hasConfigCapability: HasConfigCapabilityFn;
} {
const { getUserPrincipals, hasCapabilityForPrincipals } = deps;
let workerWarned = false;
async function hasCapability(
user: CapabilityUser,
capability: SystemCapability,
): Promise<boolean> {
if (!isMainThread && !workerWarned) {
workerWarned = true;
logger.warn(
'[hasCapability] Called from a worker thread — ALS context is unavailable. ' +
'Capability checks will hit the database on every call (no per-request caching). ' +
'If this is intentional, no action needed.',
);
}
const store = capabilityStore.getStore();
const resultKey = `${user.id}:${user.tenantId ?? ''}:${capability}`;
const cached = store?.results.get(resultKey);
if (cached !== undefined) {
return cached;
}
const principalKey = `${user.id}:${user.role}:${user.tenantId ?? ''}`;
let principals: ResolvedPrincipal[];
const cachedPrincipals = store?.principals.get(principalKey);
if (cachedPrincipals) {
principals = cachedPrincipals;
} else {
principals = await getUserPrincipals({ userId: user.id, role: user.role });
store?.principals.set(principalKey, principals);
}
const result = await hasCapabilityForPrincipals({
principals,
capability,
tenantId: user.tenantId,
});
store?.results.set(resultKey, result);
return result;
}
/**
* Checks if a user can manage or read a specific config section.
* First checks the broad capability (manage:configs / read:configs),
* then falls back to the section-specific capability (manage:configs:<section>).
*/
async function hasConfigCapability(
user: CapabilityUser,
section: ConfigSection | null,
verb: 'manage' | 'read' = 'manage',
): Promise<boolean> {
const broadCap =
verb === 'manage' ? SystemCapabilities.MANAGE_CONFIGS : SystemCapabilities.READ_CONFIGS;
if (section == null) {
return hasCapability(user, broadCap);
}
if (await hasCapability(user, broadCap)) {
return true;
}
const sectionCap =
verb === 'manage' ? configCapability(section) : readConfigCapability(section);
return hasCapability(user, sectionCap);
}
function requireCapability(capability: SystemCapability) {
return async (req: ServerRequest, res: Response, next: NextFunction) => {
try {
if (!req.user) {
res.status(401).json({ message: 'Authentication required' });
return;
}
const id = req.user.id ?? req.user._id?.toString();
if (!id) {
res.status(401).json({ message: 'Authentication required' });
return;
}
const user: CapabilityUser = {
id,
role: req.user.role ?? '',
tenantId: (req.user as CapabilityUser).tenantId,
};
if (await hasCapability(user, capability)) {
next();
return;
}
res.status(403).json({ message: 'Forbidden' });
} catch (err) {
logger.error(`[requireCapability] Error checking capability: ${capability}`, err);
res.status(500).json({ message: 'Internal Server Error' });
}
};
}
return { hasCapability, requireCapability, hasConfigCapability };
}