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.
This commit is contained in:
Marco Beretta 2026-07-03 15:03:09 +02:00
parent cc44aa402f
commit 38057713ce
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
5 changed files with 274 additions and 24 deletions

View file

@ -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;

View file

@ -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 });
}

View file

@ -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<typeof MongoMemoryServer>;
let ChatProject: mongoose.Model<IChatProject>;
let Conversation: mongoose.Model<IConversation>;
let Message: mongoose.Model<IMessage>;
let SharedLink: mongoose.Model<ISharedLink>;
let methods: ChatProjectMethods;
let modelsToCleanup: string[] = [];
@ -27,6 +36,8 @@ beforeAll(async () => {
ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
Conversation = mongoose.models.Conversation as mongoose.Model<IConversation>;
Message = mongoose.models.Message as mongoose.Model<IMessage>;
SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
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<IConversation>();
expect(conversation?.isTemporary).toBe(true);
expect(conversation?.expiredAt).toBeInstanceOf(Date);
const messages = await Message.find({ user, conversationId: 'convo-1' }).lean<IMessage[]>();
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<ISharedLink>();
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<IConversation>();
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<IConversation>();
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<IConversation[]>();
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<IMessage>();
expect(message?.isTemporary).toBe(true);
expect(message?.expiredAt).toBeInstanceOf(Date);
});
});

View file

@ -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<IChatProject | null>;
deleteChatProject(user: string, projectId: string): Promise<DeleteChatProjectResult>;
deleteChatProject(
user: string,
projectId: string,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<DeleteChatProjectResult>;
assignConversationToProject(
user: string,
conversationId: string,
projectId: string | null,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<AssignConversationToProjectResult | null>;
refreshChatProjectStats(user: string, projectId: string): Promise<IChatProject | null>;
}
@ -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<void> {
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
return Promise.resolve();
}
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const Message = mongoose.models.Message as Model<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
return cascadeForcedRetentionByProject(
Conversation,
Message,
SharedLink,
user,
chatProjectId,
resolveForcedRetentionDate(interfaceConfig),
);
}
function forceConversationRetention(
user: string,
conversationId: string,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<void> {
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
return Promise.resolve();
}
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const Message = mongoose.models.Message as Model<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
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<DeleteChatProjectResult> {
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<AssignConversationToProjectResult | null> {
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
@ -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)),
);

View file

@ -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<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
userId: string,
tag: string,
conversationMatch: FilterQuery<IConversation>,
forcedExpiredAt: Date,
): Promise<void> => {
const taggedConversations = await Conversation.find(
{ user: userId, tags: tag },
const conversations = await Conversation.find(
{ user: userId, ...conversationMatch } as FilterQuery<IConversation>,
'conversationId isTemporary expiredAt',
).lean<Array<RetentionFilterDocument & { conversationId?: string }>>();
if (taggedConversations.length === 0) {
if (conversations.length === 0) {
return;
}
const retentionBuckets = new Map<number, { expiredAt: Date; conversationIds: string[] }>();
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<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
userId: string,
tag: string,
forcedExpiredAt: Date,
): Promise<void> =>
cascadeForcedRetentionForConversationSet(
Conversation,
Message,
SharedLink,
userId,
{ tags: tag } as FilterQuery<IConversation>,
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<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
userId: string,
chatProjectId: string,
forcedExpiredAt: Date,
): Promise<void> =>
cascadeForcedRetentionForConversationSet(
Conversation,
Message,
SharedLink,
userId,
{ chatProjectId } as FilterQuery<IConversation>,
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