diff --git a/api/server/middleware/error.js b/api/server/middleware/error.js index 9c97f6bbfa..5fa3562c30 100644 --- a/api/server/middleware/error.js +++ b/api/server/middleware/error.js @@ -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', + }, ); } diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js index 8fa2f937b2..769d2b61d8 100644 --- a/api/server/routes/__test-utils__/convos-route-mocks.js +++ b/api/server/routes/__test-utils__/convos-route-mocks.js @@ -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: () => ({ diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index 763397459b..6c78287003 100644 --- a/api/server/routes/__tests__/convos.spec.js +++ b/api/server/routes/__tests__/convos.spec.js @@ -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}` }, ); diff --git a/api/server/routes/__tests__/messages-delete.spec.js b/api/server/routes/__tests__/messages-delete.spec.js index f166f1d211..44ffaf56d7 100644 --- a/api/server/routes/__tests__/messages-delete.spec.js +++ b/api/server/routes/__tests__/messages-delete.spec.js @@ -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(); }); }); diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index eb58ae764a..e579639105 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -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' }); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index e0049834f0..30097e21f7 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -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) { diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 81ac05a3a9..dbb0a15e60 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -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; diff --git a/api/server/routes/projects.js b/api/server/routes/projects.js index b8cee5205e..fd782a1ba5 100644 --- a/api/server/routes/projects.js +++ b/api/server/routes/projects.js @@ -1,6 +1,6 @@ const express = require('express'); const { createProjectHandlers } = require('@librechat/api'); -const { requireJwtAuth, 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; diff --git a/api/server/routes/share.js b/api/server/routes/share.js index ce2353cb08..05af670bfb 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -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 { diff --git a/api/server/routes/tags.js b/api/server/routes/tags.js index f6ba87d76c..a1fa1f77bb 100644 --- a/api/server/routes/tags.js +++ b/api/server/routes/tags.js @@ -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); diff --git a/api/server/services/Files/process.spec.js b/api/server/services/Files/process.spec.js index 3242bd22c4..48c37166df 100644 --- a/api/server/services/Files/process.spec.js +++ b/api/server/services/Files/process.spec.js @@ -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 }); diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js index 4a08f9bd1e..364c39f78b 100644 --- a/api/server/utils/import/fork.spec.js +++ b/api/server/utils/import/fork.spec.js @@ -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 () => { diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js index 0ad07a8d65..c382888b82 100644 --- a/api/server/utils/import/importBatchBuilder.js +++ b/api/server/utils/import/importBatchBuilder.js @@ -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.`, ); diff --git a/api/server/utils/import/importers-timestamp.spec.js b/api/server/utils/import/importers-timestamp.spec.js index 02444f00ac..268cc74c0d 100644 --- a/api/server/utils/import/importers-timestamp.spec.js +++ b/api/server/utils/import/importers-timestamp.spec.js @@ -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(), diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index a38457c8ef..46e87889e0 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -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); }); }); }); diff --git a/client/src/components/Chat/Menus/BookmarkMenu.tsx b/client/src/components/Chat/Menus/BookmarkMenu.tsx index 7cdb1a4793..a42a917fbd 100644 --- a/client/src/components/Chat/Menus/BookmarkMenu.tsx +++ b/client/src/components/Chat/Menus/BookmarkMenu.tsx @@ -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); diff --git a/packages/api/src/app/permissions.spec.ts b/packages/api/src/app/permissions.spec.ts index b5aa6e6e8b..a888d9bd6f 100644 --- a/packages/api/src/app/permissions.spec.ts +++ b/packages/api/src/app/permissions.spec.ts @@ -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: { diff --git a/packages/api/src/app/permissions.ts b/packages/api/src/app/permissions.ts index 25e73c6e17..3c9c127bce 100644 --- a/packages/api/src/app/permissions.ts +++ b/packages/api/src/app/permissions.ts @@ -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: diff --git a/packages/api/src/files/retention.spec.ts b/packages/api/src/files/retention.spec.ts index 60b65a6a0d..1fb8bc213e 100644 --- a/packages/api/src/files/retention.spec.ts +++ b/packages/api/src/files/retention.spec.ts @@ -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(); - }); }); }); diff --git a/packages/api/src/files/retention.ts b/packages/api/src/files/retention.ts index b0373bf711..f8a2ff12d7 100644 --- a/packages/api/src/files/retention.ts +++ b/packages/api/src/files/retention.ts @@ -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 { - 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( { diff --git a/packages/api/src/projects/handlers.ts b/packages/api/src/projects/handlers.ts index 0d89a8f8fd..6c05457d07 100644 --- a/packages/api/src/projects/handlers.ts +++ b/packages/api/src/projects/handlers.ts @@ -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 }); } diff --git a/packages/data-schemas/src/methods/chatProject.spec.ts b/packages/data-schemas/src/methods/chatProject.spec.ts index edd489bb3d..5563f90b6c 100644 --- a/packages/data-schemas/src/methods/chatProject.spec.ts +++ b/packages/data-schemas/src/methods/chatProject.spec.ts @@ -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; let ChatProject: mongoose.Model; let Conversation: mongoose.Model; -let Message: mongoose.Model; -let SharedLink: mongoose.Model; -let File: mongoose.Model; let methods: ChatProjectMethods; let modelsToCleanup: string[] = []; @@ -43,15 +27,7 @@ beforeAll(async () => { ChatProject = mongoose.models.ChatProject as mongoose.Model; Conversation = mongoose.models.Conversation as mongoose.Model; - Message = mongoose.models.Message as mongoose.Model; - SharedLink = mongoose.models.SharedLink as mongoose.Model; - File = mongoose.models.File as mongoose.Model; - 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(); - expect(conversation?.isTemporary).toBe(true); - expect(conversation?.expiredAt).toBeInstanceOf(Date); - - const messages = await Message.find({ - user: owner, - conversationId: 'convo-1', - }).lean(); - expect(messages).toHaveLength(2); - for (const message of messages) { - expect(message.isTemporary).toBe(true); - expect(message.expiredAt).toBeInstanceOf(Date); - } - - const share = await SharedLink.findOne({ - user: owner, - conversationId: 'convo-1', - }).lean(); - expect(share?.expiredAt).toBeInstanceOf(Date); - - const file = await File.findOne({ file_id: fileId }).lean(); - 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(); - expect(conversation?.chatProjectId).toBeUndefined(); - expect(conversation?.isTemporary).toBe(true); - expect(conversation?.expiredAt).toBeInstanceOf(Date); - }); - - it('leaves retention untouched when assigning outside ephemeral mode', async () => { - const project = await methods.createChatProject(user, { name: 'Standard' }); - await createConversation(user, 'convo-1', 'Permanent'); - - await methods.assignConversationToProject(user, 'convo-1', project._id!.toString()); - - const conversation = await Conversation.findOne({ - user, - conversationId: 'convo-1', - }).lean(); - expect(conversation?.isTemporary ?? null).not.toBe(true); - expect(conversation?.expiredAt ?? null).toBeNull(); - }); - - it('forces ephemeral retention on member chats when deleting a project', async () => { - const 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(); - 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(); - expect(message?.isTemporary).toBe(true); - expect(message?.expiredAt).toBeInstanceOf(Date); - - const file = await File.findOne({ file_id: fileId }).lean(); - expect(file?.expiredAt).toBeInstanceOf(Date); - }); }); diff --git a/packages/data-schemas/src/methods/chatProject.ts b/packages/data-schemas/src/methods/chatProject.ts index 7793b9f612..038a6a979a 100644 --- a/packages/data-schemas/src/methods/chatProject.ts +++ b/packages/data-schemas/src/methods/chatProject.ts @@ -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; - deleteChatProject( - user: string, - projectId: string, - interfaceConfig?: AppConfig['interfaceConfig'], - ): Promise; + deleteChatProject(user: string, projectId: string): Promise; assignConversationToProject( user: string, conversationId: string, projectId: string | null, - interfaceConfig?: AppConfig['interfaceConfig'], ): Promise; refreshChatProjectStats(user: string, projectId: string): Promise; } @@ -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 { 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>>(); - 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 { const ChatProject = mongoose.models.ChatProject as Model; const Conversation = mongoose.models.Conversation as Model; @@ -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: '' } } diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 0c038d94e8..8295a45c4c 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -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; - const File = () => mongoose.models.File as mongoose.Model; - - 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({ 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(); - 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; - const AclEntry = mongoose.models.AclEntry as mongoose.Model; - 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(); - expect(file?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); - - const convo = await Conversation.findOne({ 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({ - 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({ 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, - update: mongoose.UpdateQuery, - ) => { - 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(); - 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(); - 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({ 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; - 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({ 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({ 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({ 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; - const AclEntry = mongoose.models.AclEntry as mongoose.Model; - 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({ diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 6a9057fe7a..34c3e86717 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -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, - 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' && diff --git a/packages/data-schemas/src/methods/convoStructure.spec.ts b/packages/data-schemas/src/methods/convoStructure.spec.ts index f6e6e71e28..6ec2cf8c0e 100644 --- a/packages/data-schemas/src/methods/convoStructure.spec.ts +++ b/packages/data-schemas/src/methods/convoStructure.spec.ts @@ -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; diff --git a/packages/data-schemas/src/methods/file.spec.ts b/packages/data-schemas/src/methods/file.spec.ts index f31c639d1f..71b12ff5aa 100644 --- a/packages/data-schemas/src/methods/file.spec.ts +++ b/packages/data-schemas/src/methods/file.spec.ts @@ -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', () => { diff --git a/packages/data-schemas/src/methods/file.ts b/packages/data-schemas/src/methods/file.ts index 92fb024a41..2e1d9159a5 100644 --- a/packages/data-schemas/src/methods/file.ts +++ b/packages/data-schemas/src/methods/file.ts @@ -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(); + return File.findOneAndUpdate({ file_id: data.file_id }, fileData, { + new: true, + upsert: true, + }).lean(); } /** diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index 5b677ea157..e21115b8c0 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -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, diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index d37823cd4a..9f6a96cdfc 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -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['saveMessage']; let getMessages: ReturnType['getMessages']; let updateMessage: ReturnType['updateMessage']; let updateToolCallResult: ReturnType['updateToolCallResult']; -let applyForcedRetention: ApplyForcedRetention; let deleteMessages: ReturnType['deleteMessages']; let bulkSaveMessages: ReturnType['bulkSaveMessages']; let updateMessageText: ReturnType['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; - - 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; - 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; - - 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; - 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(); - 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; - 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(); - 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 diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 716f7c1694..9861b8f2e5 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -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): Promise; } -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); diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index 0b00ebb028..68188209da 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -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; -}; - -export type ApplyForcedRetention = ( - conversationId: string, - userId: string, - appConfig?: AppConfig['interfaceConfig'], -) => Promise; - export const activeExpirationFilter = < T extends RetentionFilterDocument = RetentionFilterDocument, >(): FilterQuery => @@ -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, - userId: string, - conversationId: string, - parent: Pick, -): Promise => { - const fileIds = new Set([...(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 | null => { - if (!isValidObjectIdString(userId)) { - return null; - } - return { - $or: [ - { user: userId, conversationId }, - ...(fileIds.length > 0 ? [{ user: userId, file_id: { $in: fileIds } }] : []), - ], - } as FilterQuery; -}; - -const reconcileTagCounts = async ( - Conversation: Model, - userId: string, - tags: string[], -): Promise => { - 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(), - }, - }, - { - $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; - 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 { - 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; - const Message = mongoose.models.Message as Model; - const SharedLink = mongoose.models.SharedLink as Model; - const File = mongoose.models.File as Model; - 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 | 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; - - 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; -}