diff --git a/api/server/routes/projects.js b/api/server/routes/projects.js index fd782a1ba5..b8cee5205e 100644 --- a/api/server/routes/projects.js +++ b/api/server/routes/projects.js @@ -1,6 +1,6 @@ const express = require('express'); const { createProjectHandlers } = require('@librechat/api'); -const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); +const { requireJwtAuth, configMiddleware } = require('~/server/middleware'); const db = require('~/models'); const router = express.Router(); @@ -17,9 +17,13 @@ router.use(requireJwtAuth); router.get('/', handlers.listProjects); router.post('/', handlers.createProject); -router.put('/conversations/:conversationId', handlers.assignConversationToProject); +router.put( + '/conversations/:conversationId', + configMiddleware, + handlers.assignConversationToProject, +); router.get('/:projectId', handlers.getProject); router.patch('/:projectId', handlers.updateProject); -router.delete('/:projectId', handlers.deleteProject); +router.delete('/:projectId', configMiddleware, handlers.deleteProject); module.exports = router; diff --git a/packages/api/src/projects/handlers.ts b/packages/api/src/projects/handlers.ts index 6c05457d07..0d89a8f8fd 100644 --- a/packages/api/src/projects/handlers.ts +++ b/packages/api/src/projects/handlers.ts @@ -1,6 +1,7 @@ import { isValidObjectIdString, logger } from '@librechat/data-schemas'; import type { + AppConfig, ChatProjectMethods, ChatProjectSortBy, ChatProjectSortDirection, @@ -23,6 +24,7 @@ interface ProjectUser { interface ProjectRequest extends Request { user?: ProjectUser; + config?: AppConfig; } type ProjectHandlerDependencies = Pick< @@ -139,6 +141,7 @@ export function createProjectHandlers(deps: ProjectHandlerDependencies): { getUserId(req), conversationId, projectId, + req.config?.interfaceConfig, ); if (!result) { return res.status(404).json({ error: CONVERSATION_NOT_FOUND }); @@ -208,7 +211,11 @@ export function createProjectHandlers(deps: ProjectHandlerDependencies): { } try { - const result = await deps.deleteChatProject(getUserId(req), projectId); + const result = await deps.deleteChatProject( + getUserId(req), + projectId, + req.config?.interfaceConfig, + ); if (!result.deletedCount) { return res.status(404).json({ error: PROJECT_NOT_FOUND }); } diff --git a/packages/data-schemas/src/methods/chatProject.spec.ts b/packages/data-schemas/src/methods/chatProject.spec.ts index 5563f90b6c..ca60fd0a7b 100644 --- a/packages/data-schemas/src/methods/chatProject.spec.ts +++ b/packages/data-schemas/src/methods/chatProject.spec.ts @@ -1,8 +1,15 @@ import mongoose from 'mongoose'; +import { v4 as uuidv4 } from 'uuid'; +import { RetentionMode } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import { createModels } from '~/models'; -import type { IChatProject, IConversation } from '~/types'; +import type { IChatProject, IConversation, IMessage, ISharedLink } from '~/types'; import { createChatProjectMethods, type ChatProjectMethods } from './chatProject'; +import { createModels } from '~/models'; + +const ephemeralConfig = { + temporaryChatRetention: 24, + retentionMode: RetentionMode.EPHEMERAL, +}; jest.mock('~/config/winston', () => ({ error: jest.fn(), @@ -14,6 +21,8 @@ jest.mock('~/config/winston', () => ({ let mongoServer: InstanceType; let ChatProject: mongoose.Model; let Conversation: mongoose.Model; +let Message: mongoose.Model; +let SharedLink: mongoose.Model; let methods: ChatProjectMethods; let modelsToCleanup: string[] = []; @@ -27,6 +36,8 @@ beforeAll(async () => { ChatProject = mongoose.models.ChatProject as mongoose.Model; Conversation = mongoose.models.Conversation as mongoose.Model; + Message = mongoose.models.Message as mongoose.Model; + SharedLink = mongoose.models.SharedLink as mongoose.Model; methods = createChatProjectMethods(mongoose); await mongoose.connect(mongoUri); @@ -46,6 +57,8 @@ afterAll(async () => { afterEach(async () => { await ChatProject.deleteMany({}); await Conversation.deleteMany({}); + await Message.deleteMany({}); + await SharedLink.deleteMany({}); }); async function createConversation(user: string, conversationId: string, title: string) { @@ -290,4 +303,95 @@ describe('ChatProject methods', () => { expect(assignment).toBeNull(); expect(deleteResult.deletedCount).toBe(0); }); + + it('forces ephemeral retention on a permanent chat, its messages, and shares when assigning', async () => { + const project = await methods.createChatProject(user, { name: 'Ephemeral' }); + await createConversation(user, 'convo-1', 'Permanent'); + await Message.create([ + { messageId: uuidv4(), conversationId: 'convo-1', user, text: 'first' }, + { messageId: uuidv4(), conversationId: 'convo-1', user, text: 'second' }, + ]); + await SharedLink.create({ conversationId: 'convo-1', user, shareId: uuidv4() }); + + await methods.assignConversationToProject( + user, + 'convo-1', + project._id!.toString(), + ephemeralConfig, + ); + + const conversation = await Conversation.findOne({ + user, + conversationId: 'convo-1', + }).lean(); + expect(conversation?.isTemporary).toBe(true); + expect(conversation?.expiredAt).toBeInstanceOf(Date); + + const messages = await Message.find({ user, conversationId: 'convo-1' }).lean(); + expect(messages).toHaveLength(2); + for (const message of messages) { + expect(message.isTemporary).toBe(true); + expect(message.expiredAt).toBeInstanceOf(Date); + } + + const share = await SharedLink.findOne({ user, conversationId: 'convo-1' }).lean(); + expect(share?.expiredAt).toBeInstanceOf(Date); + }); + + it('forces ephemeral retention when removing a chat from its project', async () => { + const project = await methods.createChatProject(user, { name: 'Ephemeral' }); + await createConversation(user, 'convo-1', 'Permanent'); + await methods.assignConversationToProject(user, 'convo-1', project._id!.toString()); + + await methods.assignConversationToProject(user, 'convo-1', null, ephemeralConfig); + + const conversation = await Conversation.findOne({ + user, + conversationId: 'convo-1', + }).lean(); + expect(conversation?.chatProjectId).toBeUndefined(); + expect(conversation?.isTemporary).toBe(true); + expect(conversation?.expiredAt).toBeInstanceOf(Date); + }); + + it('leaves retention untouched when assigning outside ephemeral mode', async () => { + const project = await methods.createChatProject(user, { name: 'Standard' }); + await createConversation(user, 'convo-1', 'Permanent'); + + await methods.assignConversationToProject(user, 'convo-1', project._id!.toString()); + + const conversation = await Conversation.findOne({ + user, + conversationId: 'convo-1', + }).lean(); + expect(conversation?.isTemporary ?? null).not.toBe(true); + expect(conversation?.expiredAt ?? null).toBeNull(); + }); + + it('forces ephemeral retention on member chats when deleting a project', async () => { + const project = await methods.createChatProject(user, { name: 'Ephemeral' }); + const projectId = project._id!.toString(); + await createConversation(user, 'convo-1', 'First'); + await createConversation(user, 'convo-2', 'Second'); + await methods.assignConversationToProject(user, 'convo-1', projectId); + await methods.assignConversationToProject(user, 'convo-2', projectId); + await Message.create({ messageId: uuidv4(), conversationId: 'convo-1', user, text: 'hi' }); + + await methods.deleteChatProject(user, projectId, ephemeralConfig); + + const conversations = await Conversation.find({ + user, + conversationId: { $in: ['convo-1', 'convo-2'] }, + }).lean(); + expect(conversations).toHaveLength(2); + for (const conversation of conversations) { + expect(conversation.chatProjectId).toBeUndefined(); + expect(conversation.isTemporary).toBe(true); + expect(conversation.expiredAt).toBeInstanceOf(Date); + } + + const message = await Message.findOne({ user, conversationId: 'convo-1' }).lean(); + expect(message?.isTemporary).toBe(true); + expect(message?.expiredAt).toBeInstanceOf(Date); + }); }); diff --git a/packages/data-schemas/src/methods/chatProject.ts b/packages/data-schemas/src/methods/chatProject.ts index 038a6a979a..cb7dd7fb35 100644 --- a/packages/data-schemas/src/methods/chatProject.ts +++ b/packages/data-schemas/src/methods/chatProject.ts @@ -1,9 +1,22 @@ +import { isForcedTemporaryRetention } from 'librechat-data-provider'; import type { FilterQuery, Model, SortOrder, Types } from 'mongoose'; -import logger from '~/config/winston'; +import type { + AppConfig, + IChatProject, + IChatProjectDocument, + IConversation, + IMessage, + ISharedLink, +} from '~/types'; +import { + buildRetentionVisibilityFilter, + cascadeForcedConversationRetention, + cascadeForcedRetentionByProject, + resolveForcedRetentionDate, +} from '~/utils/retention'; import { isValidObjectIdString } from '~/utils/objectId'; -import { buildRetentionVisibilityFilter } from '~/utils/retention'; import { escapeRegExp } from '~/utils/string'; -import type { IChatProject, IChatProjectDocument, IConversation } from '~/types'; +import logger from '~/config/winston'; export type ChatProjectSortBy = 'name' | 'createdAt' | 'lastConversationAt'; export type ChatProjectSortDirection = 'asc' | 'desc'; @@ -51,11 +64,16 @@ export interface ChatProjectMethods { projectId: string, input: UpdateChatProjectInput, ): Promise; - deleteChatProject(user: string, projectId: string): Promise; + deleteChatProject( + user: string, + projectId: string, + interfaceConfig?: AppConfig['interfaceConfig'], + ): Promise; assignConversationToProject( user: string, conversationId: string, projectId: string | null, + interfaceConfig?: AppConfig['interfaceConfig'], ): Promise; refreshChatProjectStats(user: string, projectId: string): Promise; } @@ -252,6 +270,54 @@ export async function updateChatProjectLastConversationForUser( } export function createChatProjectMethods(mongoose: typeof import('mongoose')): ChatProjectMethods { + /** + * Converts a project's conversations to the forced (ephemeral) window when the deployment runs + * in ephemeral mode. Assigning, removing, or bulk-unassigning a chat rewrites its row without + * setting `isTemporary`/`expiredAt`, so a permanent chat organized after the install switched + * to ephemeral would otherwise stay visible and never expire. A no-op outside forced retention. + */ + function forceProjectConversationRetention( + user: string, + chatProjectId: string, + interfaceConfig?: AppConfig['interfaceConfig'], + ): Promise { + if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) { + return Promise.resolve(); + } + const Conversation = mongoose.models.Conversation as Model; + const Message = mongoose.models.Message as Model; + const SharedLink = mongoose.models.SharedLink as Model; + return cascadeForcedRetentionByProject( + Conversation, + Message, + SharedLink, + user, + chatProjectId, + resolveForcedRetentionDate(interfaceConfig), + ); + } + + function forceConversationRetention( + user: string, + conversationId: string, + interfaceConfig?: AppConfig['interfaceConfig'], + ): Promise { + if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) { + return Promise.resolve(); + } + const Conversation = mongoose.models.Conversation as Model; + const Message = mongoose.models.Message as Model; + const SharedLink = mongoose.models.SharedLink as Model; + return cascadeForcedConversationRetention( + Conversation, + Message, + SharedLink, + user, + conversationId, + resolveForcedRetentionDate(interfaceConfig), + ); + } + async function createChatProject( user: string, input: CreateChatProjectInput, @@ -360,6 +426,7 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C async function deleteChatProject( user: string, projectId: string, + interfaceConfig?: AppConfig['interfaceConfig'], ): Promise { if (!isValidObjectIdString(projectId)) { return { deletedCount: 0, modifiedCount: 0 }; @@ -373,6 +440,8 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C return { deletedCount: 0, modifiedCount: 0 }; } + await forceProjectConversationRetention(user, projectId, interfaceConfig); + const [conversationResult, deleteResult] = await Promise.all([ Conversation.updateMany( { user, chatProjectId: projectId }, @@ -391,6 +460,7 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C user: string, conversationId: string, projectId: string | null, + interfaceConfig?: AppConfig['interfaceConfig'], ): Promise { const ChatProject = mongoose.models.ChatProject as Model; const Conversation = mongoose.models.Conversation as Model; @@ -430,6 +500,8 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C return null; } + await forceConversationRetention(user, conversationId, interfaceConfig); + const projectIds = new Set( [previousProjectId, normalizedProjectId].filter((id): id is string => Boolean(id)), ); diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index df7d33cb26..723667bcdc 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -1,6 +1,7 @@ import type { FilterQuery, Model } from 'mongoose'; -import type { IConversation, IMessage, ISharedLink } from '~/types'; -import { DEFAULT_RETENTION_HOURS } from './tempChatRetention'; +import type { AppConfig, IConversation, IMessage, ISharedLink } from '~/types'; +import { createTempChatExpirationDate, DEFAULT_RETENTION_HOURS } from './tempChatRetention'; +import logger from '~/config/winston'; export type RetentionFilterDocument = { isTemporary?: boolean | null; @@ -32,6 +33,21 @@ export const buildRetentionVisibilityFilter = < export const createFallbackRetentionDate = (now: number = Date.now()): Date => new Date(now + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000); +/** + * Resolves the forced-retention deadline from the interface config, falling back to the default + * window when the configured retention hours cannot be computed. + */ +export const resolveForcedRetentionDate = ( + interfaceConfig?: AppConfig['interfaceConfig'], +): Date => { + try { + return createTempChatExpirationDate(interfaceConfig); + } catch (err) { + logger.error('Error creating forced retention expiration date:', err); + return createFallbackRetentionDate(); + } +}; + /** * Matches retention documents that do not yet conform to a forced (ephemeral) deadline: * not temporary, missing an expiration, or expiring later than the forced window. The @@ -204,31 +220,32 @@ export const cascadeForcedConversationRetention = async ( }; /** - * Bulk-applies forced retention to every conversation carrying a bookmark tag. A tag rename - * or delete writes conversation rows directly (`Conversation.updateMany`) without setting - * `isTemporary`/`expiredAt`, so a permanent chat tagged before the install switched to - * ephemeral would otherwise stay visible and never expire. One pass converts the chats, - * backfills their messages, and caps their shares; the gap filter keeps it a no-op for chats - * that already conform and never extends a chat that already expires sooner. + * Bulk-applies forced retention to the user's conversations selected by `conversationMatch` + * (a bookmark tag, a chat project, etc.). Writes that touch these rows directly + * (`Conversation.updateMany`) without setting `isTemporary`/`expiredAt` would otherwise leave a + * permanent chat visible and non-expiring after an install switched to ephemeral. Chats are + * bucketed by their capped deadline so each bucket converts the chats, backfills their messages, + * and caps their shares in one pass; the gap filter keeps it a no-op for chats that already + * conform and never extends a chat that already expires sooner. */ -export const cascadeForcedRetentionByTag = async ( +const cascadeForcedRetentionForConversationSet = async ( Conversation: Model, Message: Model, SharedLink: Model, userId: string, - tag: string, + conversationMatch: FilterQuery, forcedExpiredAt: Date, ): Promise => { - const taggedConversations = await Conversation.find( - { user: userId, tags: tag }, + const conversations = await Conversation.find( + { user: userId, ...conversationMatch } as FilterQuery, 'conversationId isTemporary expiredAt', ).lean>(); - if (taggedConversations.length === 0) { + if (conversations.length === 0) { return; } const retentionBuckets = new Map(); - for (const convo of taggedConversations) { + for (const convo of conversations) { if (typeof convo.conversationId !== 'string' || convo.conversationId.length === 0) { continue; } @@ -278,6 +295,52 @@ export const cascadeForcedRetentionByTag = async ( } }; +/** + * Bulk-applies forced retention to every conversation carrying a bookmark tag. A tag rename or + * delete writes conversation rows directly without setting `isTemporary`/`expiredAt`, so a + * permanent chat tagged before the install switched to ephemeral would otherwise stay visible + * and never expire. + */ +export const cascadeForcedRetentionByTag = ( + Conversation: Model, + Message: Model, + SharedLink: Model, + userId: string, + tag: string, + forcedExpiredAt: Date, +): Promise => + cascadeForcedRetentionForConversationSet( + Conversation, + Message, + SharedLink, + userId, + { tags: tag } as FilterQuery, + forcedExpiredAt, + ); + +/** + * Bulk-applies forced retention to every conversation in a chat project. Assigning a chat to a + * project, removing it, or deleting the project rewrites conversation rows without setting + * `isTemporary`/`expiredAt`, so a permanent chat organized after the install switched to + * ephemeral would otherwise stay visible and never expire. + */ +export const cascadeForcedRetentionByProject = ( + Conversation: Model, + Message: Model, + SharedLink: Model, + userId: string, + chatProjectId: string, + forcedExpiredAt: Date, +): Promise => + cascadeForcedRetentionForConversationSet( + Conversation, + Message, + SharedLink, + userId, + { chatProjectId } as FilterQuery, + forcedExpiredAt, + ); + /** * One-time backfill of forced (ephemeral) retention over pre-existing data. Convert-on-touch * only converts conversations that are subsequently written, so enabling ephemeral mode on a