refactor: reduce ephemeral retention to forced temporary chats

This commit is contained in:
Marco Beretta 2026-07-28 23:06:06 +02:00
parent 744e850feb
commit 7378f1ec03
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
32 changed files with 211 additions and 2158 deletions

View file

@ -54,7 +54,9 @@ const sendError = async (req, res, options, callback) => {
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...errorMessage, user },
{ context: 'api/server/utils/streamResponse.js - sendError' },
{
context: 'api/server/utils/streamResponse.js - sendError',
},
);
}

View file

@ -72,8 +72,8 @@ module.exports = {
forkIpLimiter: (req, res, next) => next(),
forkUserLimiter: (req, res, next) => next(),
})),
configMiddleware: jest.fn((req, res, next) => next()),
validateConvoAccess: jest.fn((req, res, next) => next()),
configMiddleware: (req, res, next) => next(),
validateConvoAccess: (req, res, next) => next(),
}),
forkUtils: () => ({

View file

@ -27,7 +27,6 @@ describe('Convos Routes', () => {
deleteAllSharedLinksWithCleanup,
deleteConvoSharedLinksWithCleanup,
} = require('@librechat/api');
const { configMiddleware, validateConvoAccess } = require('~/server/middleware');
beforeAll(() => {
convosRouter = require('../convos');
@ -48,30 +47,6 @@ describe('Convos Routes', () => {
jest.clearAllMocks();
});
describe('conversation mutation middleware', () => {
it.each([
['/api/convos/archive', { arg: { conversationId: 'conv-123', isArchived: true } }],
['/api/convos/pin', { arg: { conversationId: 'conv-123', pinned: true } }],
['/api/convos/update', { arg: { conversationId: 'conv-123', title: 'Updated' } }],
])('loads config before an access denial on %s', async (path, body) => {
configMiddleware.mockImplementationOnce((req, res, next) => {
req.config = { interfaceConfig: { retentionMode: 'ephemeral' } };
next();
});
validateConvoAccess.mockImplementationOnce((req, res) => {
res.status(403).json({ configLoaded: req.config != null });
});
const response = await request(app).post(path).send(body);
expect(response.status).toBe(403);
expect(response.body).toEqual({ configLoaded: true });
expect(configMiddleware).toHaveBeenCalledTimes(1);
expect(validateConvoAccess).toHaveBeenCalledTimes(1);
expect(saveConvo).not.toHaveBeenCalled();
});
});
describe('DELETE /all', () => {
it('prunes the deleted conversations agent checkpoints (bulk, ids from deleteConvos)', async () => {
// HITL: a paused conversation's durable checkpoint must not outlive the conversation.
@ -614,7 +589,7 @@ describe('Convos Routes', () => {
expect(response.status).toBe(200);
expect(response.body).toEqual(mockPinnedConvo);
expect(saveConvo).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'test-user-123' }),
{ userId: 'test-user-123' },
{ conversationId: mockConversationId, pinned: true },
{ context: `POST /api/convos/pin ${mockConversationId}` },
);
@ -629,7 +604,7 @@ describe('Convos Routes', () => {
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUnpinnedConvo);
expect(saveConvo).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'test-user-123' }),
{ userId: 'test-user-123' },
{ conversationId: mockConversationId, pinned: false },
{ context: `POST /api/convos/pin ${mockConversationId}` },
);

View file

@ -36,7 +36,6 @@ jest.mock('~/models', () => ({
getMessages: jest.fn(),
updateMessage: jest.fn(),
deleteMessages: jest.fn(),
applyForcedRetention: jest.fn(),
getConvosQueried: jest.fn(),
searchMessages: jest.fn(),
getMessagesByCursor: jest.fn(),
@ -71,10 +70,7 @@ jest.mock('~/server/middleware', () => {
validateMessageReq,
sendValidationResponse,
prepareMessageRequestValidation,
configMiddleware: (req, res, next) => {
req.config = { interfaceConfig: { retentionMode: 'ephemeral' } };
next();
},
configMiddleware: (req, res, next) => next(),
};
});
@ -169,7 +165,7 @@ describe('deleteMessages model-level IDOR prevention', () => {
describe('DELETE /:conversationId/:messageId route handler', () => {
let app;
const { deleteMessages, applyForcedRetention } = require('~/models');
const { deleteMessages } = require('~/models');
const authenticatedUserId = 'user-owner-123';
@ -213,14 +209,6 @@ describe('DELETE /:conversationId/:messageId route handler', () => {
conversationId: 'convo-1',
user: authenticatedUserId,
});
expect(applyForcedRetention).toHaveBeenCalledWith(
'convo-1',
authenticatedUserId,
expect.objectContaining({ retentionMode: 'ephemeral' }),
);
expect(applyForcedRetention.mock.invocationCallOrder[0]).toBeGreaterThan(
deleteMessages.mock.invocationCallOrder[0],
);
});
it('should return 500 when deleteMessages throws', async () => {
@ -230,6 +218,5 @@ describe('DELETE /:conversationId/:messageId route handler', () => {
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Internal server error' });
expect(applyForcedRetention).not.toHaveBeenCalled();
});
});

View file

@ -84,7 +84,6 @@ jest.mock('~/models', () => ({
getSharedLink: jest.fn(),
getSharedLinkFile: jest.fn(),
backfillSharedLinkFiles: jest.fn(),
applyForcedRetention: jest.fn(),
getRoleByName: jest.fn(),
}));
@ -138,7 +137,6 @@ const {
updateSharedLink,
getSharedLinkFile,
backfillSharedLinkFiles,
applyForcedRetention,
getRoleByName,
} = require('~/models');
const { forkSharedConversation } = require('~/server/utils/import/fork');
@ -358,81 +356,6 @@ describe('share routes', () => {
expect(createSharedLink).not.toHaveBeenCalled();
});
it('converts the source conversation under forced retention when creating a share', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
const response = await request(buildApp({ retentionMode: RetentionMode.EPHEMERAL }))
.post('/api/share/convo-123')
.send({ targetMessageId: 'msg-123' });
expect(response.status).toBe(200);
expect(applyForcedRetention).toHaveBeenCalledWith('convo-123', 'user-123', {
retentionMode: RetentionMode.EPHEMERAL,
});
expect(applyForcedRetention).toHaveBeenCalledTimes(2);
expect(createSharedLink).toHaveBeenCalledWith(
'user-123',
'convo-123',
'msg-123',
undefined,
true,
);
expect(mockGrantCreationPermissions).toHaveBeenCalledWith(
'link-123',
'user-123',
true,
undefined,
);
expect(applyForcedRetention.mock.invocationCallOrder[1]).toBeGreaterThan(
mockGrantCreationPermissions.mock.invocationCallOrder[0],
);
});
it('converts the source conversation before creating the share so retries stay covered', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
createSharedLink.mockResolvedValue(null);
const response = await request(buildApp({ retentionMode: RetentionMode.EPHEMERAL }))
.post('/api/share/convo-123')
.send({ targetMessageId: 'msg-123' });
expect(response.status).toBe(404);
/**
* Retention runs before createSharedLink: a share attempt that fails (or hits an existing
* active share on retry) must still convert the touched conversation, otherwise a live
* share could outlast a source chat that never converts.
*/
expect(applyForcedRetention).toHaveBeenCalledWith('convo-123', 'user-123', {
retentionMode: RetentionMode.EPHEMERAL,
});
expect(applyForcedRetention.mock.invocationCallOrder[0]).toBeLessThan(
createSharedLink.mock.invocationCallOrder[0],
);
});
it('converts the source conversation under forced retention when updating a share', async () => {
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
await request(buildApp({ retentionMode: RetentionMode.EPHEMERAL }))
.patch('/api/share/share-123')
.send({ snapshotFiles: false });
expect(applyForcedRetention).toHaveBeenCalledWith('convo-123', 'user-123', {
retentionMode: RetentionMode.EPHEMERAL,
});
expect(updateSharedLink).toHaveBeenCalledWith(
'user-123',
'share-123',
undefined,
undefined,
false,
);
expect(mockUpdateSharedLinkPermissionsExpiration).not.toHaveBeenCalled();
});
it('rejects new shares for expired conversations in all retention mode', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });

View file

@ -186,7 +186,7 @@ router.delete('/all', configMiddleware, async (req, res) => {
* @param {boolean} req.body.arg.isArchived - Whether to archive (true) or unarchive (false).
* @returns {object} 200 - The updated conversation object.
*/
router.post('/archive', configMiddleware, validateConvoAccess, async (req, res) => {
router.post('/archive', validateConvoAccess, async (req, res) => {
const { conversationId, isArchived } = req.body?.arg ?? {};
if (!conversationId) {
@ -214,7 +214,7 @@ router.post('/archive', configMiddleware, validateConvoAccess, async (req, res)
}
});
router.post('/pin', configMiddleware, validateConvoAccess, async (req, res) => {
router.post('/pin', validateConvoAccess, async (req, res) => {
const { conversationId, pinned } = req.body?.arg ?? {};
if (!conversationId) {
@ -231,11 +231,7 @@ router.post('/pin', configMiddleware, validateConvoAccess, async (req, res) => {
try {
const dbResponse = await db.saveConvo(
{
userId: req.user.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
{ userId: req.user.id },
{ conversationId, pinned },
{ context: `POST /api/convos/pin ${conversationId}` },
);
@ -256,7 +252,7 @@ const MAX_CONVO_TITLE_LENGTH = 1024;
* @param {string} req.body.arg.title - The new title for the conversation.
* @returns {object} 201 - The updated conversation object.
*/
router.post('/update', configMiddleware, validateConvoAccess, async (req, res) => {
router.post('/update', validateConvoAccess, async (req, res) => {
const { conversationId, title } = req.body?.arg ?? {};
if (!conversationId) {

View file

@ -22,13 +22,6 @@ const db = require('~/models');
const router = express.Router();
router.use(requireJwtAuth);
/**
* Enforces forced (ephemeral) retention after a message-only update (edit/feedback),
* which bypasses the saveMessage/saveConvo enforcement. No-op outside forced retention.
*/
const enforceForcedRetention = (req, conversationId) =>
db.applyForcedRetention(conversationId, req?.user?.id, req?.config?.interfaceConfig);
router.get('/', async (req, res) => {
try {
const user = req.user.id ?? '';
@ -356,86 +349,79 @@ router.get('/:conversationId/:messageId', validateMessageReq, async (req, res) =
}
});
router.put(
'/:conversationId/:messageId',
validateMessageReq,
configMiddleware,
async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { text, index, model } = req.body;
router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { text, index, model } = req.body;
if (index === undefined) {
/** A user turn's persisted `quotes` are re-prepended into the prompt on
* every send, but this edit only changes `text`. Count the merged
* text+quotes so the stored `tokenCount` stays authoritative (matching the
* send path); a plain text-only count under-reports by the quote block. */
const existing = (
await db.getMessages(
{ conversationId, messageId, user: req.user.id },
'quotes isCreatedByUser',
)
)?.[0];
const textToCount = mergeQuotedTextForCount(
text,
existing?.quotes,
existing?.isCreatedByUser === true,
);
const tokenCount = await countTokens(textToCount, model);
const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount });
await enforceForcedRetention(req, conversationId);
return res.status(200).json(result);
}
if (typeof index !== 'number' || index < 0) {
return res.status(400).json({ error: 'Invalid index' });
}
const message = (
await db.getMessages({ conversationId, messageId, user: req.user.id }, 'content tokenCount')
if (index === undefined) {
/** A user turn's persisted `quotes` are re-prepended into the prompt on
* every send, but this edit only changes `text`. Count the merged
* text+quotes so the stored `tokenCount` stays authoritative (matching the
* send path); a plain text-only count under-reports by the quote block. */
const existing = (
await db.getMessages(
{ conversationId, messageId, user: req.user.id },
'quotes isCreatedByUser',
)
)?.[0];
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
const existingContent = message.content;
if (!Array.isArray(existingContent) || index >= existingContent.length) {
return res.status(400).json({ error: 'Invalid index' });
}
const updatedContent = [...existingContent];
if (!updatedContent[index]) {
return res.status(400).json({ error: 'Content part not found' });
}
const currentPartType = updatedContent[index].type;
if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
return res.status(400).json({ error: 'Cannot update non-text content' });
}
const oldText = updatedContent[index][currentPartType];
updatedContent[index] = { type: currentPartType, [currentPartType]: text };
let tokenCount = message.tokenCount;
if (tokenCount !== undefined) {
const oldTokenCount = await countTokens(oldText, model);
const newTokenCount = await countTokens(text, model);
tokenCount = Math.max(0, tokenCount - oldTokenCount) + newTokenCount;
}
const result = await db.updateMessage(req?.user?.id, {
messageId,
content: updatedContent,
tokenCount,
});
await enforceForcedRetention(req, conversationId);
const textToCount = mergeQuotedTextForCount(
text,
existing?.quotes,
existing?.isCreatedByUser === true,
);
const tokenCount = await countTokens(textToCount, model);
const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount });
return res.status(200).json(result);
} catch (error) {
logger.error('Error updating message:', error);
res.status(500).json({ error: 'Internal server error' });
}
},
);
if (typeof index !== 'number' || index < 0) {
return res.status(400).json({ error: 'Invalid index' });
}
const message = (
await db.getMessages({ conversationId, messageId, user: req.user.id }, 'content tokenCount')
)?.[0];
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
const existingContent = message.content;
if (!Array.isArray(existingContent) || index >= existingContent.length) {
return res.status(400).json({ error: 'Invalid index' });
}
const updatedContent = [...existingContent];
if (!updatedContent[index]) {
return res.status(400).json({ error: 'Content part not found' });
}
const currentPartType = updatedContent[index].type;
if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
return res.status(400).json({ error: 'Cannot update non-text content' });
}
const oldText = updatedContent[index][currentPartType];
updatedContent[index] = { type: currentPartType, [currentPartType]: text };
let tokenCount = message.tokenCount;
if (tokenCount !== undefined) {
const oldTokenCount = await countTokens(oldText, model);
const newTokenCount = await countTokens(text, model);
tokenCount = Math.max(0, tokenCount - oldTokenCount) + newTokenCount;
}
const result = await db.updateMessage(req?.user?.id, {
messageId,
content: updatedContent,
tokenCount,
});
return res.status(200).json(result);
} catch (error) {
logger.error('Error updating message:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.put(
'/:conversationId/:messageId/feedback',
@ -454,7 +440,6 @@ router.put(
},
{ context: 'updateFeedback' },
);
await enforceForcedRetention(req, conversationId);
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
@ -489,21 +474,15 @@ router.put(
},
);
router.delete(
'/:conversationId/:messageId',
validateMessageReq,
configMiddleware,
async (req, res) => {
try {
const { conversationId, messageId } = req.params;
await db.deleteMessages({ messageId, conversationId, user: req.user.id });
await enforceForcedRetention(req, conversationId);
res.status(204).send();
} catch (error) {
logger.error('Error deleting message:', error);
res.status(500).json({ error: 'Internal server error' });
}
},
);
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
try {
const { conversationId, messageId } = req.params;
await db.deleteMessages({ messageId, conversationId, user: req.user.id });
res.status(204).send();
} catch (error) {
logger.error('Error deleting message:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

View file

@ -1,6 +1,6 @@
const express = require('express');
const { createProjectHandlers } = require('@librechat/api');
const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const db = require('~/models');
const router = express.Router();
@ -17,13 +17,9 @@ router.use(requireJwtAuth);
router.get('/', handlers.listProjects);
router.post('/', handlers.createProject);
router.put(
'/conversations/:conversationId',
configMiddleware,
handlers.assignConversationToProject,
);
router.put('/conversations/:conversationId', handlers.assignConversationToProject);
router.get('/:projectId', handlers.getProject);
router.patch('/:projectId', handlers.updateProject);
router.delete('/:projectId', configMiddleware, handlers.deleteProject);
router.delete('/:projectId', handlers.deleteProject);
module.exports = router;

View file

@ -21,12 +21,7 @@ const {
SYSTEM_TENANT_ID,
createTempChatExpirationDate,
} = require('@librechat/data-schemas');
const {
FileSources,
PermissionTypes,
Permissions,
RetentionMode,
} = require('librechat-data-provider');
const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider');
const {
getFiles,
updateFile,
@ -37,7 +32,6 @@ const {
getSharedLink,
getSharedLinkFile,
backfillSharedLinkFiles,
applyForcedRetention,
getRoleByName,
} = require('~/models');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@ -74,14 +68,6 @@ const resolveSharedLinkExpiration = (req, conversationId) =>
},
);
/**
* Converts the shared source conversation (and its messages) under forced (ephemeral)
* retention, so sharing an older permanent chat does not leave it visible and non-expiring
* after the public link itself expires; a no-op outside forced retention.
*/
const enforceForcedRetention = (req, conversationId) =>
applyForcedRetention(conversationId, req?.user?.id, req?.config?.interfaceConfig);
/**
* Shared messages
*/
@ -468,22 +454,10 @@ router.post(
async (req, res) => {
try {
const { targetMessageId } = req.body;
/**
* Convert the source conversation before creating the link. createSharedLink rejects
* when an active share already exists, so a retention failure after creation would
* leave a live share whose source chat never converts no retry could reach the
* cascade again. Converting first also lets the share expiration below read the
* converted conversation's deadline.
*/
await enforceForcedRetention(req, req.params.conversationId);
const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId);
if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
return res.status(404).end();
}
const writeExpiredAt =
req.config?.interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL
? undefined
: expiredAt;
const role = await getRoleByName(req.user.role);
const sharedLinksPerms = role?.permissions?.[PermissionTypes.SHARED_LINKS] || {};
@ -496,12 +470,11 @@ router.post(
req.user.id,
req.params.conversationId,
targetMessageId,
writeExpiredAt,
expiredAt,
snapshotFiles,
);
if (created) {
await grantCreationPermissions(created._id, req.user.id, grantPublic, writeExpiredAt);
await enforceForcedRetention(req, req.params.conversationId);
await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt);
res.status(200).json(created);
} else {
res.status(404).end();
@ -532,24 +505,17 @@ router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) =>
if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
return res.status(404).end();
}
const writeExpiredAt =
req.config?.interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL
? undefined
: expiredAt;
const updatedShare = await updateSharedLink(
req.user.id,
req.params.shareId,
targetMessageId,
writeExpiredAt,
expiredAt,
isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false,
);
if (updatedShare) {
if (updatedShare._id && writeExpiredAt !== undefined) {
await updateSharedLinkPermissionsExpiration(updatedShare._id, writeExpiredAt);
}
if (existing?.conversationId) {
await enforceForcedRetention(req, existing.conversationId);
if (updatedShare._id && expiredAt !== undefined) {
await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt);
}
res.status(200).json(updatedShare);
} else {

View file

@ -1,4 +1,3 @@
const mongoose = require('mongoose');
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
@ -9,10 +8,9 @@ const {
createConversationTag,
deleteConversationTag,
getConversationTags,
applyForcedRetention,
getRoleByName,
} = require('~/models');
const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
const { requireJwtAuth } = require('~/server/middleware');
const router = express.Router();
@ -25,30 +23,6 @@ const checkBookmarkAccess = generateCheckAccess({
router.use(requireJwtAuth);
router.use(checkBookmarkAccess);
/**
* Enforces forced (ephemeral) retention after a bookmark-tag write converts an older
* permanent conversation; a no-op outside forced retention.
*/
const enforceForcedRetention = (req, conversationId) =>
applyForcedRetention(conversationId, req?.user?.id, req?.config?.interfaceConfig);
/**
* Enforces forced (ephemeral) retention on every conversation carrying a tag, for global
* tag renames/deletes that rewrite conversation rows without converting them; a no-op
* outside forced retention.
*/
const enforceForcedRetentionForTag = async (req, tag) => {
const conversations = await mongoose.models.Conversation.find(
{ user: req.user.id, tags: tag },
'conversationId',
).lean();
await Promise.all(
conversations.map(({ conversationId }) =>
applyForcedRetention(conversationId, req.user.id, req?.config?.interfaceConfig),
),
);
};
/**
* GET /
* Retrieves all conversation tags for the authenticated user.
@ -75,12 +49,9 @@ router.get('/', async (req, res) => {
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.post('/', configMiddleware, async (req, res) => {
router.post('/', async (req, res) => {
try {
const tag = await createConversationTag(req.user.id, req.body);
if (req.body?.addToConversation && req.body?.conversationId) {
await enforceForcedRetention(req, req.body.conversationId);
}
res.status(200).json(tag);
} catch (error) {
logger.error('Error creating conversation tag:', error);
@ -94,17 +65,9 @@ router.post('/', configMiddleware, async (req, res) => {
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.put('/:tag', configMiddleware, async (req, res) => {
router.put('/:tag', async (req, res) => {
try {
const decodedTag = decodeURIComponent(req.params.tag);
/**
* Enforce retention with the old tag before the rename commits. The rename rewrites the
* conversations' tag entries, so a cascade failure afterwards would 500 while a retried
* PUT /:oldTag hits the 404 path (the old tag no longer exists) and the affected chats
* never convert. The old tag selects the same conversations the renamed tag will carry,
* and enforcing on a nonexistent tag is a no-op, so a failed rename retries cleanly.
*/
await enforceForcedRetentionForTag(req, decodedTag, 'PUT /api/tags/:tag');
const tag = await updateConversationTag(req.user.id, decodedTag, req.body);
if (tag) {
res.status(200).json(tag);
@ -123,10 +86,9 @@ router.put('/:tag', configMiddleware, async (req, res) => {
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.delete('/:tag', configMiddleware, async (req, res) => {
router.delete('/:tag', async (req, res) => {
try {
const decodedTag = decodeURIComponent(req.params.tag);
await enforceForcedRetentionForTag(req, decodedTag, 'DELETE /api/tags/:tag');
const tag = await deleteConversationTag(req.user.id, decodedTag);
if (tag) {
res.status(200).json(tag);
@ -145,14 +107,13 @@ router.delete('/:tag', configMiddleware, async (req, res) => {
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.put('/convo/:conversationId', configMiddleware, async (req, res) => {
router.put('/convo/:conversationId', async (req, res) => {
try {
const conversationTags = await updateTagsForConversation(
req.user.id,
req.params.conversationId,
req.body.tags,
);
await enforceForcedRetention(req, req.params.conversationId);
res.status(200).json(conversationTags);
} catch (error) {
logger.error('Error updating conversation tags', error);

View file

@ -21,11 +21,7 @@ jest.mock('librechat-data-provider', () => {
return {
...actual,
Providers: actual.Providers,
RetentionMode: actual.RetentionMode ?? {
ALL: 'all',
TEMPORARY: 'temporary',
EPHEMERAL: 'ephemeral',
},
RetentionMode: actual.RetentionMode ?? { ALL: 'all', TEMPORARY: 'temporary' },
documentParserMimeTypes: actual.documentParserMimeTypes ?? [
/^application\/pdf$/,
/^application\/vnd\.openxmlformats-officedocument\./,
@ -39,14 +35,7 @@ jest.mock('librechat-data-provider', () => {
jest.mock('@librechat/api', () => {
const actualDataProvider = jest.requireActual('librechat-data-provider');
const RetentionMode = actualDataProvider.RetentionMode ?? {
ALL: 'all',
TEMPORARY: 'temporary',
EPHEMERAL: 'ephemeral',
};
const isAllDataRetention =
actualDataProvider.isAllDataRetention ??
((mode) => mode === RetentionMode.ALL || mode === RetentionMode.EPHEMERAL);
const RetentionMode = actualDataProvider.RetentionMode ?? { ALL: 'all', TEMPORARY: 'temporary' };
const getRetentionExpiry = jest.fn(() => ({}));
return {
sanitizeFilename: jest.fn((n) => n),
@ -63,12 +52,11 @@ jest.mock('@librechat/api', () => {
getRetentionExpiry,
getAgentFileRetentionExpiry: jest.fn(({ req, messageAttachment, toolResource }) => {
const interfaceConfig = req?.config?.interfaceConfig;
const retentionMode = interfaceConfig?.retentionMode;
if (
!messageAttachment &&
!!toolResource &&
(!isAllDataRetention(retentionMode) ||
(retentionMode === RetentionMode.ALL && interfaceConfig?.retainAgentFiles === true))
(interfaceConfig?.retentionMode !== RetentionMode.ALL ||
interfaceConfig?.retainAgentFiles === true)
) {
return {};
}
@ -655,31 +643,6 @@ describe('processAgentFileUpload', () => {
);
});
test('applies ephemeral retention metadata to persistent agent context files when retainAgentFiles is enabled', async () => {
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
getRetentionExpiry.mockResolvedValueOnce({ expiredAt });
const req = makeReq({
mimetype: PDF_MIME,
ocrConfig: null,
interfaceConfig: {
retentionMode: RetentionMode.EPHEMERAL,
retainAgentFiles: true,
},
});
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
expect(getRetentionExpiry).toHaveBeenCalledTimes(1);
expect(getRetentionExpiry.mock.calls[0][0]).toBe(req);
expect(db.createFile).toHaveBeenCalledWith(
expect.objectContaining({
expiredAt,
context: FileContext.agents,
}),
true,
);
});
test('applies retention metadata to context files uploaded as message attachments', async () => {
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
getRetentionExpiry.mockResolvedValueOnce({ expiredAt });

View file

@ -1,7 +1,6 @@
const { Constants, ForkOptions, RetentionMode } = require('librechat-data-provider');
jest.mock('~/models', () => ({
applyForcedRetention: jest.fn().mockResolvedValue(null),
getConvo: jest.fn(),
bulkSaveConvos: jest.fn(),
getMessages: jest.fn(),
@ -38,7 +37,6 @@ const {
cloneMessagesWithTimestamps,
} = require('./fork');
const {
applyForcedRetention,
bulkIncrementTagCounts,
getConvo,
bulkSaveConvos,
@ -118,10 +116,17 @@ describe('forkConversation', () => {
interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 },
});
expect(applyForcedRetention).toHaveBeenCalledWith(expect.any(String), 'user1', {
retentionMode: RetentionMode.EPHEMERAL,
temporaryChatRetention: 1,
});
expect(bulkSaveConvos).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
]),
);
expect(bulkSaveMessages).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
]),
true,
);
});
test('should fork conversation without branches', async () => {
@ -288,10 +293,17 @@ describe('duplicateConversation', () => {
interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 },
});
expect(applyForcedRetention).toHaveBeenCalledWith(expect.any(String), 'user1', {
retentionMode: RetentionMode.EPHEMERAL,
temporaryChatRetention: 1,
});
expect(bulkSaveConvos).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
]),
);
expect(bulkSaveMessages).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
]),
true,
);
});
test('should duplicate conversation and increment tag counts', async () => {

View file

@ -7,16 +7,11 @@ const {
const {
EModelEndpoint,
Constants,
RetentionMode,
openAISettings,
isAllDataRetention,
isForcedTemporaryRetention,
} = require('librechat-data-provider');
const {
applyForcedRetention,
bulkIncrementTagCounts,
bulkSaveConvos,
bulkSaveMessages,
} = require('~/models');
const { bulkIncrementTagCounts, bulkSaveConvos, bulkSaveMessages } = require('~/models');
const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults');
/**
@ -55,19 +50,16 @@ class ImportBatchBuilder {
this.retentionFields = {};
return this.retentionFields;
}
if (this.interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL) {
this.retentionFields = {};
return this.retentionFields;
}
const isTemporary = isForcedTemporaryRetention(this.interfaceConfig?.retentionMode);
try {
this.retentionFields = {
isTemporary: false,
isTemporary,
expiredAt: createTempChatExpirationDate(this.interfaceConfig),
};
} catch (error) {
logger.error('[ImportBatchBuilder] Error creating import expiration date:', error);
this.retentionFields = { isTemporary: false, expiredAt: createFallbackRetentionDate() };
this.retentionFields = { isTemporary, expiredAt: createFallbackRetentionDate() };
}
return this.retentionFields;
}
@ -160,11 +152,6 @@ class ImportBatchBuilder {
),
);
await Promise.all(promises);
await Promise.all(
this.conversations.map(({ conversationId }) =>
applyForcedRetention(conversationId, this.requestUserId, this.interfaceConfig),
),
);
logger.debug(
`user: ${this.requestUserId} | Added ${this.conversations.length} conversations and ${this.messages.length} messages to the DB.`,
);

View file

@ -5,7 +5,6 @@ const { getImporter } = require('./importers');
// Mock the database methods
jest.mock('~/models', () => ({
applyForcedRetention: jest.fn().mockResolvedValue(null),
bulkSaveConvos: jest.fn(),
bulkSaveMessages: jest.fn(),
bulkIncrementTagCounts: jest.fn(),

View file

@ -9,11 +9,7 @@ const {
} = require('librechat-data-provider');
const { getImporter, processAssistantMessage } = require('./importers');
const { ImportBatchBuilder } = require('./importBatchBuilder');
const {
applyForcedRetention,
bulkSaveMessages,
bulkSaveConvos: _bulkSaveConvos,
} = require('~/models');
const { bulkSaveMessages, bulkSaveConvos: _bulkSaveConvos } = require('~/models');
const mockGetEndpointsConfig = jest.fn().mockResolvedValue({
[EModelEndpoint.openAI]: { userProvide: false },
@ -31,7 +27,6 @@ jest.mock('~/server/controllers/ModelController', () => ({
// Mock the database methods
jest.mock('~/models', () => ({
applyForcedRetention: jest.fn().mockResolvedValue(null),
bulkSaveConvos: jest.fn(),
bulkSaveMessages: jest.fn(),
bulkIncrementTagCounts: jest.fn(),
@ -1151,7 +1146,7 @@ describe('importLibreChatConvo', () => {
expect(result.conversation.expiredAt).toBe(message.expiredAt);
});
it('routes ephemeral imports through the forced-retention chokepoint', async () => {
it('marks imported conversations and messages temporary under ephemeral retention', () => {
const requestUserId = 'user-123';
const builder = new ImportBatchBuilder(requestUserId, {
retentionMode: RetentionMode.EPHEMERAL,
@ -1161,17 +1156,11 @@ describe('importLibreChatConvo', () => {
const message = builder.addUserMessage('Ephemeral import');
const result = builder.finishConversation('Imported ephemeral chat');
await builder.saveBatch();
expect(message.isTemporary).toBeUndefined();
expect(message.expiredAt).toBeUndefined();
expect(result.conversation.isTemporary).toBeUndefined();
expect(result.conversation.expiredAt).toBeUndefined();
expect(applyForcedRetention).toHaveBeenCalledWith(
result.conversation.conversationId,
requestUserId,
{ retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 24 },
);
expect(message.isTemporary).toBe(true);
expect(message.expiredAt).toBeInstanceOf(Date);
expect(result.conversation.isTemporary).toBe(true);
expect(result.conversation.expiredAt).toBeInstanceOf(Date);
expect(result.conversation.expiredAt).toBe(message.expiredAt);
});
});
});

View file

@ -3,22 +3,18 @@ import { useRecoilValue } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { BookmarkPlusIcon } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Constants, QueryKeys } from 'librechat-data-provider';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { Constants, QueryKeys, isForcedTemporaryRetention } from 'librechat-data-provider';
import { DropdownPopup, TooltipAnchor, Spinner, useToastContext } from '@librechat/client';
import type { TConversationTag } from 'librechat-data-provider';
import type { FC } from 'react';
import type * as t from '~/common';
import {
useGetStartupConfig,
useConversationTagsQuery,
useTagConversationMutation,
} from '~/data-provider';
import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import { cn, isTemporaryConversation, logger } from '~/utils';
import { BookmarkEditDialog } from '~/components/Bookmarks';
import { useBookmarkSuccess, useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { cn, isTemporaryConversation, logger } from '~/utils';
import store from '~/store';
const BookmarkMenu: FC = () => {
@ -29,16 +25,8 @@ const BookmarkMenu: FC = () => {
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
const conversationId = conversation?.conversationId ?? '';
const updateConvoTags = useBookmarkSuccess(conversationId);
const { data: startupConfig } = useGetStartupConfig();
const tags = conversation?.tags;
/**
* A pre-existing permanent chat loaded under forced (ephemeral) retention stays
* non-temporary until the server converts it on the next write, so gate on the forced flag too
* to keep permanent-chat bookmarking hidden on a chat that is already effectively temporary.
*/
const isTemporary =
isTemporaryConversation(conversation) ||
isForcedTemporaryRetention(startupConfig?.interface?.retentionMode);
const isTemporary = isTemporaryConversation(conversation);
const menuId = useId();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);

View file

@ -234,33 +234,6 @@ describe('updateInterfacePermissions - permissions', () => {
);
});
it('preserves customized TEMPORARY_CHAT role permissions during ephemeral mode', async () => {
const config = {
interface: {
retentionMode: RetentionMode.EPHEMERAL,
},
};
const configDefaults = { interface: {} } as TConfigDefaults;
const interfaceConfig = await loadDefaultInterface({ config, configDefaults });
const appConfig = { config, interfaceConfig } as unknown as AppConfig;
mockGetRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
},
});
await updateInterfacePermissions({
appConfig,
getRoleByName: mockGetRoleByName,
updateAccessPermissions: mockUpdateAccessPermissions,
});
expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2);
for (const [, permissionsUpdate] of mockUpdateAccessPermissions.mock.calls) {
expect(permissionsUpdate).not.toHaveProperty(PermissionTypes.TEMPORARY_CHAT);
}
});
it('should call updateAccessPermissions with false when permission types are false', async () => {
const config = {
interface: {

View file

@ -29,7 +29,10 @@ function hasExplicitConfig(
case PermissionTypes.AGENTS:
return interfaceConfig?.agents !== undefined;
case PermissionTypes.TEMPORARY_CHAT:
return interfaceConfig?.temporaryChat !== undefined;
return (
interfaceConfig?.temporaryChat !== undefined ||
isForcedTemporaryRetention(interfaceConfig?.retentionMode)
);
case PermissionTypes.RUN_CODE:
return interfaceConfig?.runCode !== undefined;
case PermissionTypes.WEB_SEARCH:

View file

@ -52,7 +52,7 @@ describe('retention helpers', () => {
expect(dependencies.getConvo).not.toHaveBeenCalled();
});
it('returns a fresh initial expiry for retentionMode EPHEMERAL', async () => {
it('returns expiry when retentionMode is EPHEMERAL', async () => {
const result = await getRetentionExpiry(
request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
dependencies,
@ -304,28 +304,6 @@ describe('retention helpers', () => {
expect(dependencies.createExpirationDate).not.toHaveBeenCalled();
});
it('applies ephemeral retention to persistent agent files even when retainAgentFiles is enabled', async () => {
const result = await getAgentFileRetentionExpiry(
{
req: request({
config: {
interfaceConfig: {
retentionMode: RetentionMode.EPHEMERAL,
retainAgentFiles: true,
},
},
}),
messageAttachment: false,
toolResource: 'context',
},
dependencies,
);
expect(result).toEqual({ expiredAt: expirationDate });
expect(dependencies.getConvo).not.toHaveBeenCalled();
expect(dependencies.createExpirationDate).toHaveBeenCalledTimes(1);
});
it('still applies all-data retention to agent message attachments when retainAgentFiles is enabled', async () => {
const result = await getAgentFileRetentionExpiry(
{
@ -441,42 +419,5 @@ describe('retention helpers', () => {
).resolves.toBe(expiredAt);
expect(dependencies.createExpirationDate).not.toHaveBeenCalled();
});
it('leaves forced-retention parent capping to the chokepoint', async () => {
const conversationExpiredAt = new Date(Date.now() + 60 * 60 * 1000);
dependencies.getConvo.mockResolvedValue({ expiredAt: conversationExpiredAt });
await expect(
getSharedLinkExpiration(
{
req: request({
config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
}),
conversationId: 'convo-1',
},
dependencies,
),
).resolves.toBe(expirationDate);
});
it('returns null when creating a share expiration throws', async () => {
const conversationExpiredAt = new Date(Date.now() + 60 * 60 * 1000);
dependencies.getConvo.mockResolvedValue({ expiredAt: conversationExpiredAt });
dependencies.createExpirationDate.mockImplementation(() => {
throw new Error('boom');
});
await expect(
getSharedLinkExpiration(
{
req: request({
config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
}),
conversationId: 'convo-1',
},
dependencies,
),
).resolves.toBeNull();
});
});
});

View file

@ -1,5 +1,5 @@
import { isAllDataRetention } from 'librechat-data-provider';
import { createFallbackRetentionDate } from '@librechat/data-schemas';
import { RetentionMode, isAllDataRetention } from 'librechat-data-provider';
import type { AppConfig } from '@librechat/data-schemas';
type InterfaceConfig = AppConfig['interfaceConfig'];
@ -103,8 +103,7 @@ async function computeRetentionExpiry(
req: RetentionRequest | null | undefined,
dependencies: RetentionDependencies,
): Promise<RetentionExpiry> {
const retentionMode = req?.config?.interfaceConfig?.retentionMode;
if (isAllDataRetention(retentionMode)) {
if (isAllDataRetention(req?.config?.interfaceConfig?.retentionMode)) {
return createRetentionExpiry(req, dependencies);
}
@ -178,11 +177,10 @@ const shouldRetainPersistentAgentFile = ({
toolResource,
}: AgentFileRetentionRequest): boolean => {
const interfaceConfig = req?.config?.interfaceConfig;
const retentionMode = interfaceConfig?.retentionMode;
return (
isPersistentAgentResourceUpload({ messageAttachment, toolResource }) &&
(!isAllDataRetention(retentionMode) ||
(retentionMode === RetentionMode.ALL && interfaceConfig?.retainAgentFiles === true))
(!isAllDataRetention(interfaceConfig?.retentionMode) ||
interfaceConfig?.retainAgentFiles === true)
);
};
@ -204,9 +202,6 @@ export async function getAgentFileRetentionExpiry(
* - `undefined`: no decision can be made because the conversation id or row is missing.
* - `null`: the share should be stored without an expiration.
* - `Date`: the share should expire at that date; callers reject already-expired dates.
*
* Forced-retention parent/child alignment is deliberately not implemented here:
* `applyForcedRetention` owns that invariant after the share and its ACL entries exist.
*/
export async function getSharedLinkExpiration(
{

View file

@ -1,7 +1,6 @@
import { isValidObjectIdString, logger } from '@librechat/data-schemas';
import type {
AppConfig,
ChatProjectMethods,
ChatProjectSortBy,
ChatProjectSortDirection,
@ -24,7 +23,6 @@ interface ProjectUser {
interface ProjectRequest extends Request {
user?: ProjectUser;
config?: AppConfig;
}
type ProjectHandlerDependencies = Pick<
@ -141,7 +139,6 @@ export function createProjectHandlers(deps: ProjectHandlerDependencies): {
getUserId(req),
conversationId,
projectId,
req.config?.interfaceConfig,
);
if (!result) {
return res.status(404).json({ error: CONVERSATION_NOT_FOUND });
@ -211,11 +208,7 @@ export function createProjectHandlers(deps: ProjectHandlerDependencies): {
}
try {
const result = await deps.deleteChatProject(
getUserId(req),
projectId,
req.config?.interfaceConfig,
);
const result = await deps.deleteChatProject(getUserId(req), projectId);
if (!result.deletedCount) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}

View file

@ -1,21 +1,8 @@
import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
import { RetentionMode } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type { IChatProject, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types';
import {
createChatProjectMethods,
refreshChatProjectStatsForUser,
type ChatProjectMethods,
} from './chatProject';
import { createApplyForcedRetention } from '~/utils/retention';
import { createModels } from '~/models';
import logger from '~/config/winston';
const ephemeralConfig = {
temporaryChatRetention: 24,
retentionMode: RetentionMode.EPHEMERAL,
};
import type { IChatProject, IConversation } from '~/types';
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
@ -27,9 +14,6 @@ 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 File: mongoose.Model<IMongoFile>;
let methods: ChatProjectMethods;
let modelsToCleanup: string[] = [];
@ -43,15 +27,7 @@ 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>;
File = mongoose.models.File as mongoose.Model<IMongoFile>;
const applyForcedRetention = createApplyForcedRetention(mongoose, {
logger,
refreshProjectStats: (userId, projectId) =>
refreshChatProjectStatsForUser(mongoose, userId, projectId),
});
methods = createChatProjectMethods(mongoose, applyForcedRetention);
methods = createChatProjectMethods(mongoose);
await mongoose.connect(mongoUri);
});
@ -70,9 +46,6 @@ afterAll(async () => {
afterEach(async () => {
await ChatProject.deleteMany({});
await Conversation.deleteMany({});
await Message.deleteMany({});
await SharedLink.deleteMany({});
await File.deleteMany({});
});
async function createConversation(user: string, conversationId: string, title: string) {
@ -317,137 +290,4 @@ describe('ChatProject methods', () => {
expect(assignment).toBeNull();
expect(deleteResult.deletedCount).toBe(0);
});
it('forces ephemeral retention on a permanent chat, its messages, shares, and files when assigning', async () => {
const ownerObjectId = new mongoose.Types.ObjectId();
const owner = ownerObjectId.toString();
const project = await methods.createChatProject(owner, { name: 'Ephemeral' });
await createConversation(owner, 'convo-1', 'Permanent');
await Message.create([
{ messageId: uuidv4(), conversationId: 'convo-1', user: owner, text: 'first' },
{ messageId: uuidv4(), conversationId: 'convo-1', user: owner, text: 'second' },
]);
await SharedLink.create({ conversationId: 'convo-1', user: owner, shareId: uuidv4() });
const fileId = uuidv4();
await File.collection.insertOne({
file_id: fileId,
conversationId: 'convo-1',
user: ownerObjectId,
expiredAt: null,
});
const result = await methods.assignConversationToProject(
owner,
'convo-1',
project._id!.toString(),
ephemeralConfig,
);
expect(result?.conversation.isTemporary).toBe(true);
expect(result?.conversation.expiredAt).toBeInstanceOf(Date);
expect(result?.conversation.chatProjectId).toBe(project._id!.toString());
const conversation = await Conversation.findOne({
user: owner,
conversationId: 'convo-1',
}).lean<IConversation>();
expect(conversation?.isTemporary).toBe(true);
expect(conversation?.expiredAt).toBeInstanceOf(Date);
const messages = await Message.find({
user: owner,
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: owner,
conversationId: 'convo-1',
}).lean<ISharedLink>();
expect(share?.expiredAt).toBeInstanceOf(Date);
const file = await File.findOne({ file_id: fileId }).lean<IMongoFile>();
expect(file?.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 ownerObjectId = new mongoose.Types.ObjectId();
const owner = ownerObjectId.toString();
const project = await methods.createChatProject(owner, { name: 'Ephemeral' });
const projectId = project._id!.toString();
await createConversation(owner, 'convo-1', 'First');
await createConversation(owner, 'convo-2', 'Second');
await methods.assignConversationToProject(owner, 'convo-1', projectId);
await methods.assignConversationToProject(owner, 'convo-2', projectId);
await Message.create({
messageId: uuidv4(),
conversationId: 'convo-1',
user: owner,
text: 'hi',
});
const fileId = uuidv4();
await File.collection.insertOne({
file_id: fileId,
conversationId: 'convo-1',
user: ownerObjectId,
expiredAt: null,
});
await methods.deleteChatProject(owner, projectId, ephemeralConfig);
const conversations = await Conversation.find({
user: owner,
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: owner,
conversationId: 'convo-1',
}).lean<IMessage>();
expect(message?.isTemporary).toBe(true);
expect(message?.expiredAt).toBeInstanceOf(Date);
const file = await File.findOne({ file_id: fileId }).lean<IMongoFile>();
expect(file?.expiredAt).toBeInstanceOf(Date);
});
});

View file

@ -1,10 +1,9 @@
import type { FilterQuery, Model, SortOrder, Types } from 'mongoose';
import type { AppConfig, IChatProject, IChatProjectDocument, IConversation } from '~/types';
import type { ApplyForcedRetention } from '~/utils/retention';
import { buildRetentionVisibilityFilter } from '~/utils/retention';
import { isValidObjectIdString } from '~/utils/objectId';
import { escapeRegExp } from '~/utils/string';
import logger from '~/config/winston';
import { isValidObjectIdString } from '~/utils/objectId';
import { buildRetentionVisibilityFilter } from '~/utils/retention';
import { escapeRegExp } from '~/utils/string';
import type { IChatProject, IChatProjectDocument, IConversation } from '~/types';
export type ChatProjectSortBy = 'name' | 'createdAt' | 'lastConversationAt';
export type ChatProjectSortDirection = 'asc' | 'desc';
@ -52,16 +51,11 @@ export interface ChatProjectMethods {
projectId: string,
input: UpdateChatProjectInput,
): Promise<IChatProject | null>;
deleteChatProject(
user: string,
projectId: string,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<DeleteChatProjectResult>;
deleteChatProject(user: string, projectId: string): Promise<DeleteChatProjectResult>;
assignConversationToProject(
user: string,
conversationId: string,
projectId: string | null,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<AssignConversationToProjectResult | null>;
refreshChatProjectStats(user: string, projectId: string): Promise<IChatProject | null>;
}
@ -257,10 +251,7 @@ export async function updateChatProjectLastConversationForUser(
await ChatProject.updateOne({ _id: new mongoose.Types.ObjectId(projectId), user }, update);
}
export function createChatProjectMethods(
mongoose: typeof import('mongoose'),
applyForcedRetention: ApplyForcedRetention,
): ChatProjectMethods {
export function createChatProjectMethods(mongoose: typeof import('mongoose')): ChatProjectMethods {
async function createChatProject(
user: string,
input: CreateChatProjectInput,
@ -369,7 +360,6 @@ export function createChatProjectMethods(
async function deleteChatProject(
user: string,
projectId: string,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<DeleteChatProjectResult> {
if (!isValidObjectIdString(projectId)) {
return { deletedCount: 0, modifiedCount: 0 };
@ -383,16 +373,6 @@ export function createChatProjectMethods(
return { deletedCount: 0, modifiedCount: 0 };
}
const conversations = await Conversation.find(
{ user, chatProjectId: projectId },
'conversationId',
).lean<Array<Pick<IConversation, 'conversationId'>>>();
await Promise.all(
conversations.map(({ conversationId }) =>
applyForcedRetention(conversationId, user, interfaceConfig),
),
);
const [conversationResult, deleteResult] = await Promise.all([
Conversation.updateMany(
{ user, chatProjectId: projectId },
@ -411,7 +391,6 @@ export function createChatProjectMethods(
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>;
@ -437,16 +416,6 @@ export function createChatProjectMethods(
}
const previousProjectId = conversation.chatProjectId ?? null;
/**
* Convert the touched conversation to the forced (ephemeral) window before capturing the
* updated document. The caller returns this object straight to the client, which writes it
* into the active conversation and the React Query cache, so it must already carry the
* isTemporary/expiredAt fields rather than a stale pre-conversion snapshot. A no-op outside
* forced retention.
*/
await applyForcedRetention(conversationId, user, interfaceConfig);
const update =
normalizedProjectId == null
? { $unset: { chatProjectId: '' } }

View file

@ -1,21 +1,11 @@
import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { EModelEndpoint, ResourceType, RetentionMode } from 'librechat-data-provider';
import type {
IAclEntry,
IChatProject,
IConversation,
IMessage,
IMongoFile,
ISharedLink,
} from '../types';
import { EModelEndpoint, RetentionMode } from 'librechat-data-provider';
import type { IChatProject, IConversation } from '../types';
import { ConversationMethods, createConversationMethods } from './conversation';
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
import { refreshChatProjectStatsForUser } from './chatProject';
import { createApplyForcedRetention } from '~/utils/retention';
import { createModels } from '../models';
import logger from '~/config/winston';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
@ -57,16 +47,7 @@ beforeAll(async () => {
position: number;
}>;
const applyForcedRetention = createApplyForcedRetention(mongoose, {
logger,
refreshProjectStats: (userId, projectId) =>
refreshChatProjectStatsForUser(mongoose, userId, projectId),
});
methods = createConversationMethods(
mongoose,
{ getMessages, deleteMessages },
applyForcedRetention,
);
methods = createConversationMethods(mongoose, { getMessages, deleteMessages });
await mongoose.connect(mongoUri);
});
@ -868,444 +849,6 @@ describe('Conversation Operations', () => {
});
});
describe('forced retention message cascade', () => {
const Message = () => mongoose.models.Message as mongoose.Model<IMessage>;
const File = () => mongoose.models.File as mongoose.Model<IMongoFile>;
beforeEach(async () => {
await Message().deleteMany({});
await File().deleteMany({});
});
it('backfills existing messages when ephemeral converts a permanent conversation', async () => {
const conversationId = uuidv4();
await Conversation.create({
conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
title: 'Existing permanent chat',
});
await Message().create([
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'one' },
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'two' },
]);
await saveConvo(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
);
const convo = await Conversation.findOne<IConversation>({ conversationId }).lean();
expect(convo?.isTemporary).toBe(true);
expect(convo?.expiredAt).toBeInstanceOf(Date);
const messages = await Message().find({ conversationId }).lean();
expect(messages).toHaveLength(2);
for (const message of messages) {
expect(message.isTemporary).toBe(true);
expect(message.expiredAt).toBeInstanceOf(Date);
}
});
it('caps existing files when ephemeral converts a permanent conversation', async () => {
const conversationId = uuidv4();
const fileId = uuidv4();
const ownerObjectId = new mongoose.Types.ObjectId();
const owner = ownerObjectId.toString();
await Conversation.create({
conversationId,
user: owner,
endpoint: EModelEndpoint.openAI,
title: 'Existing permanent chat',
});
await File().collection.insertOne({
file_id: fileId,
conversationId,
user: ownerObjectId,
expiredAt: null,
});
await saveConvo(
{
userId: owner,
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
);
const file = await File().findOne({ file_id: fileId }).lean<IMongoFile>();
expect(file?.expiredAt).toBeInstanceOf(Date);
});
it('caps lagging children when saving an already-conforming temporary conversation', async () => {
const conversationId = uuidv4();
const fileId = uuidv4();
const parentDeadline = new Date(Date.now() + 60 * 60 * 1000);
const SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
const AclEntry = mongoose.models.AclEntry as mongoose.Model<IAclEntry>;
const ownerObjectId = new mongoose.Types.ObjectId();
const owner = ownerObjectId.toString();
await Conversation.create({
conversationId,
user: owner,
endpoint: EModelEndpoint.openAI,
title: 'Conforming temporary chat',
isTemporary: true,
expiredAt: parentDeadline,
});
const share = await SharedLink.create({
conversationId,
user: owner,
shareId: uuidv4(),
expiredAt: parentDeadline,
});
await AclEntry.collection.insertOne({
resourceType: ResourceType.SHARED_LINK,
resourceId: share._id,
expiredAt: null,
});
await File().collection.insertOne({
file_id: fileId,
conversationId,
user: ownerObjectId,
expiredAt: null,
});
await saveConvo(
{
userId: owner,
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
);
const reloadedShare = await SharedLink.findOne({ conversationId }).lean();
expect(reloadedShare?.expiredAt?.getTime()).toBe(parentDeadline.getTime());
const aclEntry = await AclEntry.findOne({ resourceId: reloadedShare?._id }).lean();
expect(aclEntry?.expiredAt?.getTime()).toBe(parentDeadline.getTime());
const file = await File().findOne({ file_id: fileId }).lean<IMongoFile>();
expect(file?.expiredAt?.getTime()).toBe(parentDeadline.getTime());
const convo = await Conversation.findOne<IConversation>({ conversationId }).lean();
expect(convo?.expiredAt?.getTime()).toBe(parentDeadline.getTime());
});
it('retries child alignment even when a failed backfill left the parent conforming', async () => {
const conversationId = uuidv4();
const owner = new mongoose.Types.ObjectId().toString();
await Conversation.create({
conversationId,
user: owner,
endpoint: EModelEndpoint.openAI,
title: 'Existing permanent chat',
});
await Message().create({ messageId: uuidv4(), conversationId, user: owner, text: 'hi' });
const spy = jest.spyOn(File(), 'updateMany').mockImplementationOnce(() => {
throw new Error('file backfill failed');
});
const failed = await saveConvo(
{
userId: owner,
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
);
spy.mockRestore();
expect(failed).toEqual({ message: 'Error saving conversation' });
const convertedParent = await Conversation.findOne<IConversation>({
conversationId,
}).lean();
expect(convertedParent?.isTemporary).toBe(true);
expect(convertedParent?.expiredAt).toBeInstanceOf(Date);
await saveConvo(
{
userId: owner,
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
);
const converted = await Conversation.findOne<IConversation>({ conversationId }).lean();
expect(converted?.isTemporary).toBe(true);
expect(converted?.expiredAt).toBeInstanceOf(Date);
const message = await Message().findOne({ conversationId }).lean();
expect(message?.isTemporary).toBe(true);
expect(message?.expiredAt).toBeInstanceOf(Date);
});
it('converges children on the next chokepoint call after a concurrent parent shortening', async () => {
const conversationId = uuidv4();
const ownerObjectId = new mongoose.Types.ObjectId();
const owner = ownerObjectId.toString();
const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000);
const messageId = uuidv4();
const convo = await Conversation.create({
conversationId,
user: owner,
endpoint: EModelEndpoint.openAI,
title: 'Concurrent retention chat',
});
await Message().create({ messageId, conversationId, user: owner, text: 'child' });
const FileModel = File();
const updateMany = FileModel.updateMany.bind(FileModel);
let shortened = false;
const racingUpdateMany = jest.fn(
async (
filter: mongoose.FilterQuery<IMongoFile>,
update: mongoose.UpdateQuery<IMongoFile>,
) => {
if (!shortened) {
shortened = true;
await Conversation.collection.updateOne(
{ _id: convo._id },
{ $set: { isTemporary: true, expiredAt: soonerExpiry } },
);
}
return updateMany(filter, update);
},
);
Object.assign(FileModel, { updateMany: racingUpdateMany });
await (async () => {
try {
return await saveConvo(
{
userId: owner,
interfaceConfig: {
temporaryChatRetention: 24,
retentionMode: RetentionMode.EPHEMERAL,
},
},
{ conversationId, isArchived: true },
);
} finally {
Object.assign(FileModel, { updateMany });
}
})();
const parent = await Conversation.findById(convo._id).lean<IConversation>();
expect(parent?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
const converged = await saveConvo(
{
userId: owner,
interfaceConfig: {
temporaryChatRetention: 24,
retentionMode: RetentionMode.EPHEMERAL,
},
},
{ conversationId, isArchived: true },
);
expect(converged?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
const message = await Message().findOne({ messageId }).lean<IMessage>();
expect(message?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
});
it('converts an active retained (all-mode) conversation when switching to ephemeral', async () => {
const conversationId = uuidv4();
const retainedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
await Conversation.create({
conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
title: 'Retained all-mode chat',
isTemporary: false,
expiredAt: retainedUntil,
});
await Message().create({
messageId: uuidv4(),
conversationId,
user: 'user123',
text: 'retained',
isTemporary: false,
expiredAt: retainedUntil,
});
await saveConvo(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
);
const convo = await Conversation.findOne<IConversation>({ conversationId }).lean();
expect(convo?.isTemporary).toBe(true);
expect(convo?.expiredAt?.getTime()).toBeLessThan(retainedUntil.getTime());
const messages = await Message().find({ conversationId }).lean();
expect(messages).toHaveLength(1);
expect(messages[0].isTemporary).toBe(true);
expect(messages[0].expiredAt?.getTime()).toBeLessThan(retainedUntil.getTime());
});
it('preserves an earlier retained expiration when switching to ephemeral', async () => {
const SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
await SharedLink.deleteMany({});
const conversationId = uuidv4();
const soonerExpiry = new Date(Date.now() + 60 * 60 * 1000);
await Conversation.create({
conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
title: 'Sooner retained all-mode chat',
isTemporary: false,
expiredAt: soonerExpiry,
});
await Message().create({
messageId: uuidv4(),
conversationId,
user: 'user123',
text: 'retained',
isTemporary: false,
expiredAt: soonerExpiry,
});
await SharedLink.create({
conversationId,
user: 'user123',
shareId: uuidv4(),
expiredAt: soonerExpiry,
});
await saveConvo(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
);
const convo = await Conversation.findOne<IConversation>({ conversationId }).lean();
expect(convo?.isTemporary).toBe(true);
expect(convo?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
const message = await Message().findOne({ conversationId }).lean();
expect(message?.isTemporary).toBe(true);
expect(message?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
const share = await SharedLink.findOne({ conversationId }).lean();
expect(share?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
});
it('re-caps an already temporary conversation and its messages to a shorter window', async () => {
const conversationId = uuidv4();
const longerExpiry = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
await Conversation.create({
conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
title: 'Already temporary chat',
isTemporary: true,
expiredAt: longerExpiry,
});
await Message().create({
messageId: uuidv4(),
conversationId,
user: 'user123',
text: 'older temporary message',
isTemporary: true,
expiredAt: longerExpiry,
});
await saveConvo(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
);
const convo = await Conversation.findOne<IConversation>({ conversationId }).lean();
expect(convo?.expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime());
const messages = await Message().find({ conversationId }).lean();
expect(messages).toHaveLength(1);
expect(messages[0].isTemporary).toBe(true);
expect(messages[0].expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime());
});
it('heals a permanent message row inside an already-ephemeral conversation', async () => {
const conversationId = uuidv4();
const ctx = {
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
};
await saveConvo(ctx, { conversationId, title: 'Already ephemeral' });
const parent = await Conversation.findOne<IConversation>({ conversationId }).lean();
expect(parent?.expiredAt).toBeInstanceOf(Date);
const lateMessage = await Message().create({
messageId: uuidv4(),
conversationId,
user: 'user123',
text: 'legacy row without retention fields',
});
await saveConvo(ctx, { conversationId, isArchived: true });
const reloaded = await Message().findById(lateMessage._id).lean();
expect(reloaded?.isTemporary).toBe(true);
expect(reloaded?.expiredAt?.getTime()).toBe(parent?.expiredAt?.getTime());
});
it('caps an existing permanent shared link when ephemeral converts a conversation', async () => {
const SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
const AclEntry = mongoose.models.AclEntry as mongoose.Model<IAclEntry>;
await SharedLink.deleteMany({});
await AclEntry.deleteMany({});
const conversationId = uuidv4();
const soonerAclExpiry = new Date(Date.now() + 30 * 60 * 1000);
await Conversation.create({
conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
title: 'Existing permanent chat',
});
const share = await SharedLink.create({
conversationId,
user: 'user123',
shareId: uuidv4(),
});
expect(share.expiredAt ?? null).toBeNull();
await AclEntry.collection.insertMany([
{
resourceType: ResourceType.SHARED_LINK,
resourceId: share._id,
expiredAt: null,
},
{
resourceType: ResourceType.SHARED_LINK,
resourceId: share._id,
expiredAt: soonerAclExpiry,
},
]);
await saveConvo(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
);
const reloaded = await SharedLink.findOne({ conversationId }).lean();
expect(reloaded?.expiredAt).toBeInstanceOf(Date);
const entries = await AclEntry.find({ resourceId: share._id }).sort({ expiredAt: -1 }).lean();
expect(entries).toHaveLength(2);
expect(entries[0].expiredAt?.getTime()).toBe(reloaded?.expiredAt?.getTime());
expect(entries[1].expiredAt?.getTime()).toBe(soonerAclExpiry.getTime());
});
});
describe('searchConversation', () => {
it('should find a conversation by conversationId', async () => {
await Conversation.create({

View file

@ -2,7 +2,6 @@ import { RetentionMode } from 'librechat-data-provider';
import type { FilterQuery, Model, SortOrder } from 'mongoose';
import type { DeleteResult } from 'mongoose';
import type { AppConfig, IChatProjectDocument, IConversation } from '~/types';
import type { ApplyForcedRetention } from '~/utils/retention';
import type { MessageMethods } from './message';
import {
refreshChatProjectStatsForUser,
@ -71,7 +70,6 @@ export interface ConversationMethods {
export function createConversationMethods(
mongoose: typeof import('mongoose'),
messageMethods?: Pick<MessageMethods, 'getMessages' | 'deleteMessages'>,
applyForcedRetention?: ApplyForcedRetention,
): ConversationMethods {
function getMessageMethods() {
if (!messageMethods) {
@ -255,10 +253,14 @@ export function createConversationMethods(
}
if (interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL) {
delete update.isTemporary;
delete update.expiredAt;
delete unsetFields.isTemporary;
delete unsetFields.expiredAt;
update.isTemporary = true;
try {
update.expiredAt = createTempChatExpirationDate(interfaceConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
update.expiredAt = createFallbackRetentionDate();
}
} else if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;
@ -329,18 +331,6 @@ export function createConversationMethods(
return null;
}
if (applyForcedRetention) {
const forcedExpiredAt = await applyForcedRetention(
conversation.conversationId ?? conversationId,
userId,
interfaceConfig,
);
if (forcedExpiredAt instanceof Date) {
conversation.isTemporary = true;
conversation.expiredAt = forcedExpiredAt;
}
}
if (
interfaceConfig?.retentionMode === RetentionMode.ALL &&
typeof isTemporary !== 'boolean' &&

View file

@ -1,10 +1,10 @@
import mongoose from 'mongoose';
import type { TMessage } from 'librechat-data-provider';
import { buildTree } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type { TMessage } from 'librechat-data-provider';
import type { IMessage } from '..';
import { createMessageMethods } from './message';
import { createModels } from '~/models';
import { createMessageMethods } from './message';
import type { IMessage } from '..';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
@ -26,7 +26,7 @@ beforeAll(async () => {
Object.assign(mongoose.models, models);
Message = mongoose.models.Message;
const methods = createMessageMethods(mongoose, async () => null);
const methods = createMessageMethods(mongoose);
getMessages = methods.getMessages;
bulkSaveMessages = methods.bulkSaveMessages;

View file

@ -1,9 +1,9 @@
import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { EToolResources, FileContext, FileSources } from 'librechat-data-provider';
import { EToolResources, FileContext } from 'librechat-data-provider';
import { _resetStrictCache } from '~/models/plugins/tenantIsolation';
import { runAsSystem, tenantStorage } from '~/config/tenantContext';
import { runAsSystem } from '~/config/tenantContext';
import { createFileMethods } from './file';
import { createModels } from '~/models';
@ -63,9 +63,6 @@ describe('File Methods', () => {
expect(file).not.toBeNull();
expect(file?.file_id).toBe(fileId);
expect(file?.filename).toBe('test.txt');
expect(file?.object).toBe('file');
expect(file?.usage).toBe(0);
expect(file?.source).toBe(FileSources.local);
expect(file?.expiresAt).toBeDefined();
});
@ -89,100 +86,6 @@ describe('File Methods', () => {
expect(file?.file_id).toBe(fileId);
expect(file?.expiresAt).toBeUndefined();
});
it('casts string owner ids in atomic pipeline upserts', async () => {
const fileId = uuidv4();
const userId = new mongoose.Types.ObjectId();
const file = await fileMethods.createFile({
file_id: fileId,
user: userId.toString() as unknown as mongoose.Types.ObjectId,
filename: 'owned.txt',
filepath: '/uploads/owned.txt',
type: 'text/plain',
bytes: 100,
});
expect(file?.user).toEqual(userId);
await expect(File.countDocuments({ file_id: fileId, user: userId })).resolves.toBe(1);
});
it('casts caller-supplied timestamps in atomic pipeline upserts', async () => {
const createdAt = '2026-07-26T10:15:30.000Z';
const file = await fileMethods.createFile({
file_id: uuidv4(),
user: new mongoose.Types.ObjectId(),
filename: 'timestamped.png',
filepath: '/uploads/timestamped.png',
type: 'image/png',
bytes: 100,
createdAt: createdAt as unknown as Date,
});
expect(file?.createdAt).toBeInstanceOf(Date);
expect(file?.createdAt?.toISOString()).toBe(createdAt);
});
it('rejects cross-tenant mutation fields before atomic pipeline upserts', async () => {
const userId = new mongoose.Types.ObjectId();
await expect(
tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
fileMethods.createFile({
file_id: uuidv4(),
user: userId,
tenantId: 'tenant-b',
filename: 'cross-tenant.txt',
filepath: '/uploads/cross-tenant.txt',
type: 'text/plain',
bytes: 100,
}),
),
).rejects.toThrow('Cross-tenant tenantId mutation is not allowed');
});
it('derives matching tenant ids from the tenant-scoped upsert filter', async () => {
const fileId = uuidv4();
const file = await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
fileMethods.createFile({
file_id: fileId,
user: new mongoose.Types.ObjectId(),
tenantId: 'tenant-a',
filename: 'tenant-owned.txt',
filepath: '/uploads/tenant-owned.txt',
type: 'text/plain',
bytes: 100,
}),
);
expect(file?.tenantId).toBe('tenant-a');
await expect(
runAsSystem(() => File.countDocuments({ file_id: fileId, tenantId: 'tenant-a' })),
).resolves.toBe(1);
});
it('updates expiredAt monotonically with an atomic minimum', async () => {
const fileId = uuidv4();
const userId = new mongoose.Types.ObjectId();
const firstExpiry = new Date('2030-01-01T00:00:00.000Z');
const laterExpiry = new Date('2031-01-01T00:00:00.000Z');
const soonerExpiry = new Date('2029-01-01T00:00:00.000Z');
const data = {
file_id: fileId,
user: userId,
filename: 'retained.txt',
filepath: '/uploads/retained.txt',
type: 'text/plain',
bytes: 100,
};
await fileMethods.createFile({ ...data, expiredAt: firstExpiry }, true);
const notExtended = await fileMethods.createFile({ ...data, expiredAt: laterExpiry }, true);
const shortened = await fileMethods.createFile({ ...data, expiredAt: soonerExpiry }, true);
expect(notExtended?.expiredAt?.getTime()).toBe(firstExpiry.getTime());
expect(shortened?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
});
});
describe('claimCodeFile', () => {

View file

@ -1,7 +1,6 @@
import { EToolResources, FileContext, FileSources } from 'librechat-data-provider';
import { EToolResources, FileContext } from 'librechat-data-provider';
import type { FilterQuery, SortOrder, Model } from 'mongoose';
import type { IMongoFile } from '~/types/file';
import { getTenantId, SYSTEM_TENANT_ID } from '~/config/tenantContext';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
import logger from '../config/winston';
@ -365,45 +364,10 @@ export function createFileMethods(mongoose: typeof import('mongoose')): {
delete fileData.expiresAt;
}
const uncastFields = Object.fromEntries(
Object.entries(fileData).filter(([, value]) => value !== undefined),
);
const activeTenantId = getTenantId();
if (activeTenantId !== SYSTEM_TENANT_ID) {
if (
activeTenantId &&
Object.prototype.hasOwnProperty.call(uncastFields, 'tenantId') &&
uncastFields.tenantId !== activeTenantId
) {
throw new Error('[TenantIsolation] Cross-tenant tenantId mutation is not allowed');
}
delete uncastFields.tenantId;
}
const { expiredAt, ...definedFields } = File.castObject(uncastFields);
const insertDefaults = {
object: { $ifNull: ['$object', 'file'] },
usage: { $ifNull: ['$usage', 0] },
source: { $ifNull: ['$source', FileSources.local] },
};
let expiryUpdate = {};
if (expiredAt instanceof Date) {
expiryUpdate = {
expiredAt: {
$min: [{ $ifNull: ['$expiredAt', expiredAt] }, expiredAt],
},
};
}
return File.findOneAndUpdate(
{ file_id: data.file_id },
[{ $set: { ...insertDefaults, ...definedFields, ...expiryUpdate } }],
{
new: true,
upsert: true,
},
).lean<IMongoFile>();
return File.findOneAndUpdate({ file_id: data.file_id }, fileData, {
new: true,
upsert: true,
}).lean<IMongoFile>();
}
/**

View file

@ -46,14 +46,7 @@ import { createPresetMethods, type PresetMethods } from './preset';
import { createConversationTagMethods, type ConversationTagMethods } from './conversationTag';
import { createMessageMethods, type MessageMethods } from './message';
import { createConversationMethods, type ConversationMethods } from './conversation';
import {
createChatProjectMethods,
refreshChatProjectStatsForUser,
type ChatProjectMethods,
} from './chatProject';
import type { ApplyForcedRetention } from '~/utils/retention';
import { createApplyForcedRetention } from '~/utils/retention';
import logger from '~/config/winston';
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
export type {
AssignConversationToProjectResult,
ChatProjectSortBy,
@ -152,7 +145,8 @@ export type AllMethods = UserMethods &
ConversationTagMethods &
MessageMethods &
ConversationMethods &
ChatProjectMethods & { applyForcedRetention: ApplyForcedRetention } & TxMethods &
ChatProjectMethods &
TxMethods &
TransactionMethods &
SpendTokensMethods &
PromptMethods &
@ -206,21 +200,12 @@ export function createMethods(
createStructuredTransaction: transactionMethods.createStructuredTransaction,
});
const applyForcedRetention = createApplyForcedRetention(mongoose, {
logger,
refreshProjectStats: (userId, projectId) =>
refreshChatProjectStatsForUser(mongoose, userId, projectId),
});
const messageMethods = createMessageMethods(mongoose, applyForcedRetention);
const messageMethods = createMessageMethods(mongoose);
const conversationMethods = createConversationMethods(
mongoose,
{
getMessages: messageMethods.getMessages,
deleteMessages: messageMethods.deleteMessages,
},
applyForcedRetention,
);
const conversationMethods = createConversationMethods(mongoose, {
getMessages: messageMethods.getMessages,
deleteMessages: messageMethods.deleteMessages,
});
// ACL entry methods (used internally for removeAllPermissions)
const aclEntryMethods = createAclEntryMethods(mongoose);
@ -294,8 +279,7 @@ export function createMethods(
...createConversationTagMethods(mongoose),
...messageMethods,
...conversationMethods,
...createChatProjectMethods(mongoose, applyForcedRetention),
applyForcedRetention,
...createChatProjectMethods(mongoose),
/* Tier 3 */
...txMethods,
...transactionMethods,

View file

@ -2,11 +2,8 @@ import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
import { RetentionMode } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type { IChatProject, IConversation, IMessage, ISharedLink } from '..';
import type { ApplyForcedRetention } from '../utils/retention';
import type { IMessage } from '..';
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
import { createApplyForcedRetention } from '../utils/retention';
import { refreshChatProjectStatsForUser } from './chatProject';
import { createMessageMethods } from './message';
import { createModels } from '../models';
import logger from '~/config/winston';
@ -26,7 +23,6 @@ let saveMessage: ReturnType<typeof createMessageMethods>['saveMessage'];
let getMessages: ReturnType<typeof createMessageMethods>['getMessages'];
let updateMessage: ReturnType<typeof createMessageMethods>['updateMessage'];
let updateToolCallResult: ReturnType<typeof createMessageMethods>['updateToolCallResult'];
let applyForcedRetention: ApplyForcedRetention;
let deleteMessages: ReturnType<typeof createMessageMethods>['deleteMessages'];
let bulkSaveMessages: ReturnType<typeof createMessageMethods>['bulkSaveMessages'];
let updateMessageText: ReturnType<typeof createMessageMethods>['updateMessageText'];
@ -41,12 +37,7 @@ beforeAll(async () => {
Object.assign(mongoose.models, models);
Message = mongoose.models.Message;
applyForcedRetention = createApplyForcedRetention(mongoose, {
logger,
refreshProjectStats: (userId, projectId) =>
refreshChatProjectStatsForUser(mongoose, userId, projectId),
});
const methods = createMessageMethods(mongoose, applyForcedRetention);
const methods = createMessageMethods(mongoose);
saveMessage = methods.saveMessage;
getMessages = methods.getMessages;
updateMessage = methods.updateMessage;
@ -1016,525 +1007,6 @@ describe('Message Operations', () => {
});
});
describe('forced retention conversation cascade', () => {
const Conversation = () => mongoose.models.Conversation as mongoose.Model<IConversation>;
beforeEach(async () => {
await Conversation().deleteMany({});
});
it('converts a permanent parent conversation and backfills its messages', async () => {
const conversationId = uuidv4();
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Existing permanent chat',
});
const olderMessage = await saveMessage(
{ userId: 'user123' },
{ messageId: uuidv4(), conversationId, text: 'older', user: 'user123' },
);
expect(olderMessage?.expiredAt ?? null).toBeNull();
await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
);
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.isTemporary).toBe(true);
expect(convo?.expiredAt).toBeInstanceOf(Date);
const messages = await getMessages({ conversationId, user: 'user123' });
expect(messages).toHaveLength(2);
for (const message of messages) {
expect(message.isTemporary).toBe(true);
expect(message.expiredAt).toBeInstanceOf(Date);
}
});
it('converts an active retained (all-mode) parent when switching to ephemeral', async () => {
const conversationId = uuidv4();
const retainedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Retained all-mode chat',
isTemporary: false,
expiredAt: retainedUntil,
});
await Message.create({
messageId: uuidv4(),
conversationId,
user: 'user123',
text: 'retained',
isTemporary: false,
expiredAt: retainedUntil,
});
await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
);
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.isTemporary).toBe(true);
expect(convo?.expiredAt?.getTime()).toBeLessThan(retainedUntil.getTime());
const messages = await getMessages({ conversationId, user: 'user123' });
expect(messages).toHaveLength(2);
for (const message of messages) {
expect(message.isTemporary).toBe(true);
expect(message.expiredAt?.getTime()).toBeLessThan(retainedUntil.getTime());
}
});
it('preserves a carried-over message that already expires sooner than the forced window', async () => {
const conversationId = uuidv4();
const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Permanent chat with a sooner-expiring message',
isTemporary: false,
});
const soonerMessageId = uuidv4();
await Message.create({
messageId: soonerMessageId,
conversationId,
user: 'user123',
text: 'all-mode message with its own sooner TTL',
isTemporary: false,
expiredAt: soonerExpiry,
});
await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'follow-up', user: 'user123' },
);
const sooner = await Message.findOne({ messageId: soonerMessageId }).lean();
expect(sooner?.isTemporary).toBe(true);
expect(sooner?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
});
it('re-caps an already temporary parent and its messages to a shorter ephemeral window', async () => {
const conversationId = uuidv4();
const longerExpiry = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Already temporary chat',
isTemporary: true,
expiredAt: longerExpiry,
});
await Message.create({
messageId: uuidv4(),
conversationId,
user: 'user123',
text: 'older temporary message',
isTemporary: true,
expiredAt: longerExpiry,
});
await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
);
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime());
const messages = await getMessages({ conversationId, user: 'user123' });
expect(messages).toHaveLength(2);
for (const message of messages) {
expect(message.isTemporary).toBe(true);
expect(message.expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime());
}
});
it('caps a message-only save to a sooner parent expiry instead of orphaning it', async () => {
const conversationId = uuidv4();
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Ephemeral chat expiring soon',
isTemporary: true,
expiredAt: parentExpiry,
});
const saved = await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
{ context: 'branch' },
);
expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0);
});
it('caps a message-only save to an all-mode parent expiring sooner without extending it', async () => {
const conversationId = uuidv4();
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'All-mode chat expiring soon',
isTemporary: false,
expiredAt: parentExpiry,
});
const olderMessageId = uuidv4();
await Message.create({
messageId: olderMessageId,
conversationId,
user: 'user123',
text: 'older retained message',
isTemporary: false,
expiredAt: parentExpiry,
});
const saved = await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
{ context: 'branch' },
);
expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.isTemporary).toBe(true);
expect(convo?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
const older = await Message.findOne({ messageId: olderMessageId }).lean();
expect(older?.isTemporary).toBe(true);
expect(older?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
});
it('backfills lagging messages when the parent already expires sooner', async () => {
const conversationId = uuidv4();
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Ephemeral chat expiring soon',
isTemporary: true,
expiredAt: parentExpiry,
});
const laggingMessageId = uuidv4();
await Message.create({
messageId: laggingMessageId,
conversationId,
user: 'user123',
text: 'older message with no expiry',
});
await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
{ context: 'branch' },
);
const lagging = await Message.findOne({ messageId: laggingMessageId }).lean();
expect(lagging?.isTemporary).toBe(true);
expect(lagging?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
});
it('caps a normal send to a parent expiring sooner so it cannot outlive the conversation', async () => {
const conversationId = uuidv4();
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Ephemeral chat expiring soon',
isTemporary: true,
expiredAt: parentExpiry,
});
const saved = await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'follow-up', user: 'user123' },
);
expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0);
});
it('does not touch the parent conversation outside forced retention', async () => {
const conversationId = uuidv4();
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Existing permanent chat',
});
await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.TEMPORARY },
},
{ messageId: uuidv4(), conversationId, text: 'edit', user: 'user123' },
);
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.expiredAt ?? null).toBeNull();
});
it('caps an existing permanent shared link when converting via a message save', async () => {
const SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
await SharedLink.deleteMany({});
const conversationId = uuidv4();
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Existing permanent chat',
});
await SharedLink.create({ conversationId, user: 'user123', shareId: uuidv4() });
await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
{ context: 'branch' },
);
const reloaded = await SharedLink.findOne({ conversationId }).lean();
expect(reloaded?.expiredAt).toBeInstanceOf(Date);
});
});
describe('applyForcedRetention', () => {
const Conversation = () => mongoose.models.Conversation as mongoose.Model<IConversation>;
beforeEach(async () => {
await Conversation().deleteMany({});
});
it('converts a permanent message and its conversation when editing under ephemeral mode', async () => {
const conversationId = uuidv4();
const editedMessageId = uuidv4();
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Existing permanent chat',
});
await Message.create([
{ messageId: editedMessageId, conversationId, user: 'user123', text: 'first' },
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'second' },
]);
await applyForcedRetention(conversationId, 'user123', {
temporaryChatRetention: 24,
retentionMode: RetentionMode.EPHEMERAL,
});
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.isTemporary).toBe(true);
expect(convo?.expiredAt).toBeInstanceOf(Date);
const messages = await getMessages({ conversationId, user: 'user123' });
expect(messages).toHaveLength(2);
for (const message of messages) {
expect(message.isTemporary).toBe(true);
expect(message.expiredAt).toBeInstanceOf(Date);
}
});
it('preserves an edited message expiring sooner than its parent under forced retention', async () => {
const conversationId = uuidv4();
const editedMessageId = uuidv4();
const messageDeadline = new Date(Date.now() + 60 * 60 * 1000);
const parentDeadline = new Date(Date.now() + 12 * 60 * 60 * 1000);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Carried-over chat',
isTemporary: false,
expiredAt: parentDeadline,
});
await Message.create({
messageId: editedMessageId,
conversationId,
user: 'user123',
text: 'edited',
isTemporary: false,
expiredAt: messageDeadline,
});
await applyForcedRetention(conversationId, 'user123', {
temporaryChatRetention: 24,
retentionMode: RetentionMode.EPHEMERAL,
});
const message = await Message.findOne({ messageId: editedMessageId, user: 'user123' }).lean();
expect(message?.isTemporary).toBe(true);
expect(message?.expiredAt?.getTime()).toBe(messageDeadline.getTime());
});
it('refreshes owning project stats when forced retention hides a project chat', async () => {
const ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
await ChatProject.deleteMany({});
const conversationId = uuidv4();
const project = await ChatProject.create({
name: 'Ephemeral Project',
user: 'user123',
conversationCount: 1,
lastConversationId: conversationId,
lastConversationAt: new Date(),
});
const projectId = project._id!.toString();
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Project chat',
isTemporary: false,
chatProjectId: projectId,
});
await Message.create({ messageId: uuidv4(), conversationId, user: 'user123', text: 'hi' });
await applyForcedRetention(conversationId, 'user123', {
temporaryChatRetention: 24,
retentionMode: RetentionMode.EPHEMERAL,
});
const refreshed = await ChatProject.findById(projectId).lean<IChatProject>();
expect(refreshed?.conversationCount).toBe(0);
expect(refreshed?.lastConversationId ?? null).toBeNull();
});
it('refreshes owning project stats when a message-only save converts a project chat', async () => {
const ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
await ChatProject.deleteMany({});
const conversationId = uuidv4();
const project = await ChatProject.create({
name: 'Ephemeral Project',
user: 'user123',
conversationCount: 1,
lastConversationId: conversationId,
lastConversationAt: new Date(),
});
const projectId = project._id!.toString();
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Project chat',
isTemporary: false,
chatProjectId: projectId,
});
await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
{ context: 'branch' },
);
const refreshed = await ChatProject.findById(projectId).lean<IChatProject>();
expect(refreshed?.conversationCount).toBe(0);
expect(refreshed?.lastConversationId ?? null).toBeNull();
});
it('converts a permanent conversation and its messages without a messageId (tag write)', async () => {
const conversationId = uuidv4();
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Existing permanent chat',
});
await Message.create([
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'first' },
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'second' },
]);
await applyForcedRetention(conversationId, 'user123', {
temporaryChatRetention: 24,
retentionMode: RetentionMode.EPHEMERAL,
});
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.isTemporary).toBe(true);
expect(convo?.expiredAt).toBeInstanceOf(Date);
const messages = await getMessages({ conversationId, user: 'user123' });
expect(messages).toHaveLength(2);
for (const message of messages) {
expect(message.isTemporary).toBe(true);
expect(message.expiredAt).toBeInstanceOf(Date);
}
});
it('is a no-op outside forced retention', async () => {
const conversationId = uuidv4();
const messageId = uuidv4();
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Existing permanent chat',
});
await Message.create({ messageId, conversationId, user: 'user123', text: 'first' });
await applyForcedRetention(conversationId, 'user123', {
temporaryChatRetention: 24,
retentionMode: RetentionMode.TEMPORARY,
});
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.expiredAt ?? null).toBeNull();
const message = await Message.findOne({ messageId }).lean();
expect(message?.expiredAt ?? null).toBeNull();
});
});
describe('Message cursor pagination', () => {
/**
* Helper to create messages with specific timestamps

View file

@ -1,6 +1,5 @@
import { RetentionMode } from 'librechat-data-provider';
import type { DeleteResult, FilterQuery, Model } from 'mongoose';
import type { ApplyForcedRetention } from '~/utils/retention';
import type { AppConfig, IMessage } from '~/types';
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
import { createFallbackRetentionDate } from '~/utils/retention';
@ -75,10 +74,7 @@ export interface MessageMethods {
deleteMessages(filter: FilterQuery<IMessage>): Promise<DeleteResult>;
}
export function createMessageMethods(
mongoose: typeof import('mongoose'),
applyForcedRetention: ApplyForcedRetention,
): MessageMethods {
export function createMessageMethods(mongoose: typeof import('mongoose')): MessageMethods {
/**
* Saves a message in the database.
*/
@ -116,8 +112,14 @@ export function createMessageMethods(
};
if (interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL) {
delete update.isTemporary;
delete update.expiredAt;
update.isTemporary = true;
try {
update.expiredAt = createTempChatExpirationDate(interfaceConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
update.expiredAt = createFallbackRetentionDate();
}
} else if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;
@ -150,7 +152,6 @@ export function createMessageMethods(
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
update.tokenCount = 0;
}
const message = await Message.findOneAndUpdate(
{ messageId: params.messageId, user: userId },
update,
@ -170,17 +171,6 @@ export function createMessageMethods(
message.isTemporary = false;
}
const forcedExpiredAt = await applyForcedRetention(conversationId, userId, interfaceConfig);
if (forcedExpiredAt instanceof Date) {
message.isTemporary = true;
if (
!(message.expiredAt instanceof Date) ||
message.expiredAt.getTime() > forcedExpiredAt.getTime()
) {
message.expiredAt = forcedExpiredAt;
}
}
return message.toObject();
} catch (err: unknown) {
logger.error('Error saving message:', err);

View file

@ -1,38 +1,11 @@
import { ResourceType, RetentionMode } from 'librechat-data-provider';
import type { FilterQuery, Model, Types } from 'mongoose';
import type {
AppConfig,
IAclEntry,
IConversation,
IMessage,
IMongoFile,
ISharedLink,
} from '~/types';
import type { IConversationTag } from '~/schema/conversationTag';
import { createTempChatExpirationDate, DEFAULT_RETENTION_HOURS } from './tempChatRetention';
import { tenantSafeBulkWrite } from './tenantBulkWrite';
import { isValidObjectIdString } from './objectId';
import type { FilterQuery } from 'mongoose';
import { DEFAULT_RETENTION_HOURS } from './tempChatRetention';
export type RetentionFilterDocument = {
isTemporary?: boolean | null;
expiredAt?: Date | null;
};
type RetentionLogger = {
error: (message: string, error?: unknown) => void;
};
type ApplyForcedRetentionDependencies = {
logger: RetentionLogger;
refreshProjectStats: (userId: string, projectId: string) => Promise<unknown>;
};
export type ApplyForcedRetention = (
conversationId: string,
userId: string,
appConfig?: AppConfig['interfaceConfig'],
) => Promise<Date | null>;
export const activeExpirationFilter = <
T extends RetentionFilterDocument = RetentionFilterDocument,
>(): FilterQuery<T> =>
@ -57,206 +30,3 @@ export const buildRetentionVisibilityFilter = <
export const createFallbackRetentionDate = (now: number = Date.now()): Date =>
new Date(now + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000);
const expirationPipeline = (expiredAt: Date, includeTemporary = false) => [
{
$set: {
...(includeTemporary ? { isTemporary: true } : {}),
expiredAt: { $min: [{ $ifNull: ['$expiredAt', expiredAt] }, expiredAt] },
},
},
];
const resolveForcedRetentionDate = (
appConfig: AppConfig['interfaceConfig'] | undefined,
logger: RetentionLogger,
): Date => {
try {
return createTempChatExpirationDate(appConfig);
} catch (error) {
logger.error('Error creating forced-retention expiration date:', error);
return createFallbackRetentionDate();
}
};
const collectReferencedFileIds = async (
Message: Model<IMessage>,
userId: string,
conversationId: string,
parent: Pick<IConversation, 'files' | 'file_ids'>,
): Promise<string[]> => {
const fileIds = new Set<string>([...(parent.files ?? []), ...(parent.file_ids ?? [])]);
const messages = await Message.find(
{
user: userId,
conversationId,
$or: [{ files: { $exists: true, $ne: null } }, { attachments: { $exists: true, $ne: null } }],
},
'files attachments',
).lean<
Array<{
files?: Array<{ file_id?: unknown } | null>;
attachments?: Array<{ file_id?: unknown } | null>;
}>
>();
const addReferences = (references?: Array<{ file_id?: unknown } | null>) => {
for (const reference of references ?? []) {
if (typeof reference?.file_id === 'string' && reference.file_id.length > 0) {
fileIds.add(reference.file_id);
}
}
};
for (const message of messages) {
addReferences(message.files);
addReferences(message.attachments);
}
return [...fileIds].filter(Boolean);
};
const createOwnedFileScope = (
userId: string,
conversationId: string,
fileIds: string[],
): FilterQuery<IMongoFile> | null => {
if (!isValidObjectIdString(userId)) {
return null;
}
return {
$or: [
{ user: userId, conversationId },
...(fileIds.length > 0 ? [{ user: userId, file_id: { $in: fileIds } }] : []),
],
} as FilterQuery<IMongoFile>;
};
const reconcileTagCounts = async (
Conversation: Model<IConversation>,
userId: string,
tags: string[],
): Promise<void> => {
const uniqueTags = [...new Set(tags.filter(Boolean))];
if (uniqueTags.length === 0) {
return;
}
const counts = await Conversation.aggregate<{ _id: string; count: number }>([
{
$match: {
user: userId,
tags: { $in: uniqueTags },
...buildRetentionVisibilityFilter<IConversation>(),
},
},
{
$project: {
tags: { $setIntersection: [{ $ifNull: ['$tags', []] }, uniqueTags] },
},
},
{ $unwind: '$tags' },
{ $group: { _id: '$tags', count: { $sum: 1 } } },
]);
const countByTag = new Map(counts.map(({ _id, count }) => [_id, count]));
const ConversationTag = Conversation.db.models.ConversationTag as Model<IConversationTag>;
await tenantSafeBulkWrite(
ConversationTag,
uniqueTags.map((tag) => ({
updateOne: {
filter: { user: userId, tag },
update: { $set: { count: countByTag.get(tag) ?? 0 } },
},
})),
);
};
export function createApplyForcedRetention(
mongoose: typeof import('mongoose'),
{ logger, refreshProjectStats }: ApplyForcedRetentionDependencies,
): ApplyForcedRetention {
/**
* Applies the complete forced-retention invariant for one owned conversation.
*
* `expiredAt` is a monotonic cap: the conversation and every dependent record keep an
* earlier existing deadline, while null/missing/later deadlines move to the configured
* forced deadline. Every expiry mutation is a server-side aggregation-pipeline `$min`;
* no value derived from a prior read is ever written back with `$set`.
*
* The operation is idempotent and always aligns every child, even when the parent already
* conforms. A partial failure therefore needs only another call; parent conformity is never
* used as a sentinel that would suppress retrying child work.
*/
async function applyForcedRetention(
conversationId: string,
userId: string,
appConfig?: AppConfig['interfaceConfig'],
): Promise<Date | null> {
if (appConfig?.retentionMode !== RetentionMode.EPHEMERAL) {
return null;
}
if (
typeof conversationId !== 'string' ||
conversationId.length === 0 ||
typeof userId !== 'string' ||
userId.length === 0
) {
return null;
}
const proposedExpiredAt = resolveForcedRetentionDate(appConfig, logger);
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const Message = mongoose.models.Message as Model<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const File = mongoose.models.File as Model<IMongoFile>;
const parent = await Conversation.findOneAndUpdate(
{ conversationId, user: userId },
expirationPipeline(proposedExpiredAt, true),
{
new: true,
projection: {
expiredAt: 1,
files: 1,
file_ids: 1,
tags: 1,
chatProjectId: 1,
},
},
).lean<Pick<
IConversation,
'expiredAt' | 'files' | 'file_ids' | 'tags' | 'chatProjectId'
> | null>();
const expiredAt = parent?.expiredAt instanceof Date ? parent.expiredAt : proposedExpiredAt;
const [fileIds, sharedLinks] = await Promise.all([
collectReferencedFileIds(Message, userId, conversationId, parent ?? {}),
SharedLink.find({ conversationId, user: userId }, '_id').lean<
Array<{ _id: Types.ObjectId }>
>(),
]);
const sharedLinkIds = sharedLinks.map(({ _id }) => _id);
const fileScope = createOwnedFileScope(userId, conversationId, fileIds);
const AclEntry = SharedLink.db.models.AclEntry as Model<IAclEntry>;
await Promise.all([
Message.updateMany({ conversationId, user: userId }, expirationPipeline(expiredAt, true)),
...(sharedLinkIds.length > 0
? [
SharedLink.updateMany({ _id: { $in: sharedLinkIds } }, expirationPipeline(expiredAt)),
AclEntry.updateMany(
{
resourceType: ResourceType.SHARED_LINK,
resourceId: { $in: sharedLinkIds },
},
expirationPipeline(expiredAt),
),
]
: []),
...(fileScope != null ? [File.updateMany(fileScope, expirationPipeline(expiredAt))] : []),
]);
await reconcileTagCounts(Conversation, userId, parent?.tags ?? []);
if (typeof parent?.chatProjectId === 'string' && parent.chatProjectId.length > 0) {
await refreshProjectStats(userId, parent.chatProjectId);
}
return expiredAt;
}
return applyForcedRetention;
}