LibreChat/packages/api/src/projects/handlers.ts
Marco Beretta 38057713ce
fix: enforce ephemeral retention on chat project membership changes
Project membership writes bypassed forced retention: assigning a chat to a
project, removing it, or deleting the project rewrote the conversation row
(chatProjectId) without setting isTemporary/expiredAt. On a deployment that
switched to ephemeral mode, touching an older permanent chat this way left it
visible and non-expiring.

Load the interface config on the assign and delete project routes and cascade
forced retention through the touched conversation(s): assign/remove caps the
single conversation, its messages, and its shares; delete caps every member
chat before unassigning. Extract the bucketed tag cascade into a shared helper
so the project cascade reuses the same conversion/backfill/cap logic, and add a
shared resolveForcedRetentionDate helper. All paths are a no-op outside forced
retention, so non-ephemeral behavior is unchanged.
2026-07-03 15:03:09 +02:00

237 lines
7.5 KiB
TypeScript

import { isValidObjectIdString, logger } from '@librechat/data-schemas';
import type {
AppConfig,
ChatProjectMethods,
ChatProjectSortBy,
ChatProjectSortDirection,
CreateChatProjectInput,
UpdateChatProjectInput,
} from '@librechat/data-schemas';
import type { Request, Response } from 'express';
const PROJECT_NOT_FOUND = 'Project not found';
const CONVERSATION_NOT_FOUND = 'Conversation not found';
const PROJECT_SORT_FIELDS = new Set<ChatProjectSortBy>(['name', 'createdAt', 'lastConversationAt']);
interface ProjectUser {
id: string;
_id?: {
toString(): string;
};
}
interface ProjectRequest extends Request {
user?: ProjectUser;
config?: AppConfig;
}
type ProjectHandlerDependencies = Pick<
ChatProjectMethods,
| 'listChatProjects'
| 'createChatProject'
| 'getChatProject'
| 'updateChatProject'
| 'deleteChatProject'
| 'assignConversationToProject'
>;
const getUserId = (req: ProjectRequest): string => req.user?.id ?? req.user?._id?.toString() ?? '';
const queryString = (value: Request['query'][string]): string | undefined => {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return queryString(value[0]);
}
return undefined;
};
const normalizeString = (value: string | null | undefined): string =>
typeof value === 'string' ? value.trim() : '';
const normalizeLimit = (value: Request['query'][string]): number => {
const limit = parseInt(queryString(value) ?? '', 10);
if (!Number.isFinite(limit)) {
return 25;
}
return Math.min(Math.max(limit, 1), 100);
};
const normalizeSortBy = (value: Request['query'][string]): ChatProjectSortBy | undefined => {
const sortBy = queryString(value);
return PROJECT_SORT_FIELDS.has(sortBy as ChatProjectSortBy)
? (sortBy as ChatProjectSortBy)
: undefined;
};
const normalizeSortDirection = (
value: Request['query'][string],
): ChatProjectSortDirection | undefined => {
const sortDirection = queryString(value);
return sortDirection === 'asc' || sortDirection === 'desc' ? sortDirection : undefined;
};
const createProjectInput = (req: ProjectRequest): CreateChatProjectInput | null => {
const name = normalizeString(req.body?.name);
if (!name) {
return null;
}
return {
name,
description: typeof req.body?.description === 'string' ? req.body.description : '',
};
};
export function createProjectHandlers(deps: ProjectHandlerDependencies): {
listProjects: (req: ProjectRequest, res: Response) => Promise<Response>;
createProject: (req: ProjectRequest, res: Response) => Promise<Response>;
assignConversationToProject: (req: ProjectRequest, res: Response) => Promise<Response>;
getProject: (req: ProjectRequest, res: Response) => Promise<Response>;
updateProject: (req: ProjectRequest, res: Response) => Promise<Response>;
deleteProject: (req: ProjectRequest, res: Response) => Promise<Response>;
} {
async function listProjects(req: ProjectRequest, res: Response): Promise<Response> {
try {
const result = await deps.listChatProjects(getUserId(req), {
cursor: queryString(req.query.cursor),
limit: normalizeLimit(req.query.limit),
sortBy: normalizeSortBy(req.query.sortBy),
sortDirection: normalizeSortDirection(req.query.sortDirection),
search: queryString(req.query.search),
});
return res.status(200).json(result);
} catch (error) {
logger.error('[projects] Error listing projects', error);
return res.status(500).json({ error: 'Error listing projects' });
}
}
async function createProject(req: ProjectRequest, res: Response): Promise<Response> {
const input = createProjectInput(req);
if (!input) {
return res.status(400).json({ error: 'name is required' });
}
try {
const project = await deps.createChatProject(getUserId(req), input);
return res.status(201).json(project);
} catch (error) {
logger.error('[projects] Error creating project', error);
return res.status(500).json({ error: 'Error creating project' });
}
}
async function assignConversationToProject(
req: ProjectRequest,
res: Response,
): Promise<Response> {
const { conversationId } = req.params;
const projectId = req.body?.projectId ?? null;
if (projectId !== null && typeof projectId !== 'string') {
return res.status(400).json({ error: 'projectId must be a string or null' });
}
try {
const result = await deps.assignConversationToProject(
getUserId(req),
conversationId,
projectId,
req.config?.interfaceConfig,
);
if (!result) {
return res.status(404).json({ error: CONVERSATION_NOT_FOUND });
}
return res.status(200).json(result);
} catch (error) {
if (error instanceof Error && error.message === PROJECT_NOT_FOUND) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
logger.error('[projects] Error assigning conversation to project', error);
return res.status(500).json({ error: 'Error assigning conversation to project' });
}
}
async function getProject(req: ProjectRequest, res: Response): Promise<Response> {
const { projectId } = req.params;
if (!isValidObjectIdString(projectId)) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
try {
const project = await deps.getChatProject(getUserId(req), projectId);
if (!project) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
return res.status(200).json(project);
} catch (error) {
logger.error('[projects] Error getting project', error);
return res.status(500).json({ error: 'Error getting project' });
}
}
async function updateProject(req: ProjectRequest, res: Response): Promise<Response> {
const { projectId } = req.params;
if (!isValidObjectIdString(projectId)) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
const input: UpdateChatProjectInput = {};
if (req.body?.name !== undefined) {
const name = normalizeString(req.body.name);
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
input.name = name;
}
if (req.body?.description !== undefined) {
input.description = typeof req.body.description === 'string' ? req.body.description : '';
}
try {
const project = await deps.updateChatProject(getUserId(req), projectId, input);
if (!project) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
return res.status(200).json(project);
} catch (error) {
logger.error('[projects] Error updating project', error);
return res.status(500).json({ error: 'Error updating project' });
}
}
async function deleteProject(req: ProjectRequest, res: Response): Promise<Response> {
const { projectId } = req.params;
if (!isValidObjectIdString(projectId)) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
try {
const result = await deps.deleteChatProject(
getUserId(req),
projectId,
req.config?.interfaceConfig,
);
if (!result.deletedCount) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
return res.status(200).json(result);
} catch (error) {
logger.error('[projects] Error deleting project', error);
return res.status(500).json({ error: 'Error deleting project' });
}
}
return {
listProjects,
createProject,
assignConversationToProject,
getProject,
updateProject,
deleteProject,
};
}