LibreChat/packages/api/src/admin/users.ts
Danny Avila aa35cd42b1
📬 feat: Add Durable Agent Trigger Delivery (#14925)
* feat: wire trusted agent trigger dispatch

* feat: add durable agent trigger delivery

* fix: annotate trigger envelope byte limit

* test: isolate trigger startup in server specs

* fix: fence trigger delivery during account deletion

* test: isolate trigger service in user controller specs

* fix: close trigger deletion admission race

* fix: harden account deletion fences

* fix: close durable trigger review gaps

* fix: require offline stale-fence recovery

* fix: type trigger lane sequence ids

* fix: fence admin user deletion triggers

* fix: make trigger deletion recovery durable

* fix: harden offline user deletion

* fix: serialize trigger lane publication

* style: sort trigger delivery imports

* fix: recover orphaned trigger publications

* fix: preserve trigger recovery ordering

* fix: fence trigger publication during purge

* fix: defer remote trigger deletion fences

* fix: close durable delivery cleanup races

* fix: drain CLI generation owners before deletion
2026-08-17 09:25:08 -04:00

249 lines
9.1 KiB
TypeScript

import { Types } from 'mongoose';
import { PrincipalType, SystemRoles } from 'librechat-data-provider';
import { logger, isValidObjectIdString } from '@librechat/data-schemas';
import type {
IUser,
IConfig,
AdminUserListItem,
AdminUserSearchResult,
UserDeleteResult,
} from '@librechat/data-schemas';
import type { FilterQuery } from 'mongoose';
import type { Response } from 'express';
import type { ServerRequest } from '~/types/http';
import { parsePagination } from './pagination';
const MAX_SEARCH_LENGTH = 200;
const USER_LIST_FIELDS = '_id name username email avatar role provider createdAt updatedAt';
export interface AdminUsersDeps {
findUsers: (
searchCriteria: FilterQuery<IUser>,
fieldsToSelect?: string | string[] | null,
options?: { limit?: number; offset?: number; sort?: Record<string, 1 | -1> },
) => Promise<IUser[]>;
countUsers: (filter?: FilterQuery<IUser>) => Promise<number>;
beginAgentTriggerUserDeletion: (
userId: string,
startedAt: Date,
) => Promise<'acquired' | 'in_progress' | 'missing'>;
cancelAgentTriggerUserDeletion: (userId: string, startedAt: Date) => Promise<boolean>;
drainAgentTriggerDeliveriesForUser: (userId: string) => Promise<void>;
prepareAgentTriggerUserPurge: (
userId: string,
fenceStartedAt: Date,
tenantId?: string,
) => Promise<void>;
cancelAgentTriggerUserPurge: (userId: string, fenceStartedAt: Date) => Promise<boolean>;
purgeAgentTriggerDeliveriesForUser: (userId: string) => Promise<void>;
/**
* Thin data-layer delete — removes the User document only.
* Full cascade of user-owned resources (conversations, messages, files, tokens, etc.)
* is handled by `UserController.deleteUserController` in the self-delete flow.
* This admin endpoint fences durable triggers around the user commit and currently
* cascades Config and AclEntries.
* A future iteration should consolidate the full cascade into a shared service function.
*/
deleteUserById: (userId: string) => Promise<UserDeleteResult>;
deleteConfig: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
) => Promise<IConfig | null>;
deleteAclEntries: (filter: {
principalType: PrincipalType;
principalId: string | Types.ObjectId;
}) => Promise<void>;
}
export function createAdminUsersHandlers(deps: AdminUsersDeps): {
listUsers: (req: ServerRequest, res: Response) => Promise<Response>;
searchUsers: (req: ServerRequest, res: Response) => Promise<Response>;
deleteUser: (req: ServerRequest, res: Response) => Promise<Response>;
} {
const {
findUsers,
countUsers,
beginAgentTriggerUserDeletion,
cancelAgentTriggerUserDeletion,
drainAgentTriggerDeliveriesForUser,
prepareAgentTriggerUserPurge,
cancelAgentTriggerUserPurge,
purgeAgentTriggerDeliveriesForUser,
deleteUserById,
deleteConfig,
deleteAclEntries,
} = deps;
async function listUsersHandler(req: ServerRequest, res: Response) {
try {
const { limit, offset } = parsePagination(req.query);
const [users, total] = await Promise.all([
findUsers({}, USER_LIST_FIELDS, { limit, offset, sort: { createdAt: -1 } }),
countUsers(),
]);
const mapped: AdminUserListItem[] = users.map((u) => ({
id: u._id?.toString() ?? '',
name: u.name ?? '',
username: u.username ?? '',
email: u.email ?? '',
avatar: u.avatar ?? '',
role: u.role ?? 'USER',
provider: u.provider ?? 'local',
createdAt: u.createdAt?.toISOString(),
updatedAt: u.updatedAt?.toISOString(),
}));
return res.status(200).json({ users: mapped, total, limit, offset });
} catch (error) {
logger.error('[adminUsers] listUsers error:', error);
return res.status(500).json({ error: 'Failed to list users' });
}
}
async function searchUsersHandler(req: ServerRequest, res: Response) {
try {
const rawQ = req.query.q;
const rawLimit = req.query.limit;
const query = typeof rawQ === 'string' ? rawQ : undefined;
const limitStr = typeof rawLimit === 'string' ? rawLimit : '20';
const trimmed = query?.trim() ?? '';
if (!trimmed) {
return res.status(400).json({ error: 'Query parameter "q" is required' });
}
if (trimmed.length < 2) {
return res.status(400).json({ error: 'Query must be at least 2 characters' });
}
if (trimmed.length > MAX_SEARCH_LENGTH) {
return res
.status(400)
.json({ error: `Query must not exceed ${MAX_SEARCH_LENGTH} characters` });
}
const searchLimit = Math.min(Math.max(1, parseInt(limitStr, 10) || 20), 50);
const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`^${escaped}`, 'i');
const users = await findUsers(
{ $or: [{ name: regex }, { email: regex }, { username: regex }] },
'_id name email username avatar',
{ limit: searchLimit, sort: { name: 1 } },
);
const results: AdminUserSearchResult[] = users.map((u) => ({
id: u._id?.toString() ?? '',
name: u.name ?? '',
email: u.email ?? '',
username: u.username,
avatarUrl: u.avatar,
}));
return res
.status(200)
.json({ users: results, total: results.length, capped: results.length >= searchLimit });
} catch (error) {
logger.error('[adminUsers] searchUsers error:', error);
return res.status(500).json({ error: 'Failed to search users' });
}
}
async function deleteUserHandler(req: ServerRequest, res: Response) {
let targetUserId: string | undefined;
let triggerDeletionFence: Date | undefined;
let userDeleted = false;
try {
const { id } = req.params as { id: string };
targetUserId = id;
if (!isValidObjectIdString(id)) {
return res.status(400).json({ error: 'Invalid user ID format' });
}
const callerId = req.user?._id?.toString() ?? req.user?.id;
if (callerId === id) {
return res.status(403).json({ error: 'Cannot delete your own account' });
}
const [targetUser] = await findUsers({ _id: id }, 'role tenantId', { limit: 1 });
if (targetUser?.role === SystemRoles.ADMIN) {
const adminCount = await countUsers({ role: SystemRoles.ADMIN });
if (adminCount <= 1) {
return res.status(400).json({ error: 'Cannot delete the last admin user' });
}
}
triggerDeletionFence = new Date();
const fenceState = await beginAgentTriggerUserDeletion(id, triggerDeletionFence);
if (fenceState === 'in_progress') {
triggerDeletionFence = undefined;
return res.status(409).json({ error: 'User deletion is already in progress' });
}
if (fenceState === 'missing') {
triggerDeletionFence = undefined;
return res.status(404).json({ error: 'User not found' });
}
await prepareAgentTriggerUserPurge(id, triggerDeletionFence, targetUser?.tenantId);
await drainAgentTriggerDeliveriesForUser(id);
const result = await deleteUserById(id);
if (result.deletedCount === 0) {
await cancelAgentTriggerUserPurge(id, triggerDeletionFence);
await cancelAgentTriggerUserDeletion(id, triggerDeletionFence);
triggerDeletionFence = undefined;
return res.status(404).json({ error: 'User not found' });
}
userDeleted = true;
await purgeAgentTriggerDeliveriesForUser(id);
if (targetUser?.role === SystemRoles.ADMIN) {
const remaining = await countUsers({ role: SystemRoles.ADMIN });
if (remaining === 0) {
logger.error(
`[adminUsers] CRITICAL: last admin deleted via race condition, user: ${id}. ` +
'Manual DB intervention required to restore an ADMIN user.',
);
}
}
const objectId = new Types.ObjectId(id);
const cleanupResults = await Promise.allSettled([
deleteConfig(PrincipalType.USER, id),
deleteAclEntries({ principalType: PrincipalType.USER, principalId: objectId }),
]);
for (const r of cleanupResults) {
if (r.status === 'rejected') {
logger.error('[adminUsers] cascade cleanup failed for user:', id, r.reason);
}
}
return res.status(200).json({ message: result.message || 'User deleted successfully' });
} catch (error) {
if (targetUserId != null && triggerDeletionFence != null && !userDeleted) {
try {
await cancelAgentTriggerUserPurge(targetUserId, triggerDeletionFence);
} catch (purgeFenceError) {
logger.error('[adminUsers] failed to disarm trigger purge recovery:', purgeFenceError);
}
try {
await cancelAgentTriggerUserDeletion(targetUserId, triggerDeletionFence);
} catch (fenceError) {
logger.error('[adminUsers] failed to release trigger deletion fence:', fenceError);
}
}
logger.error('[adminUsers] deleteUser error:', error);
return res.status(500).json({ error: 'Failed to delete user' });
}
}
return {
listUsers: listUsersHandler,
searchUsers: searchUsersHandler,
deleteUser: deleteUserHandler,
};
}