diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 35fe640476..e11b3db4ab 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -2,7 +2,7 @@ import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { EModelEndpoint, RetentionMode } from 'librechat-data-provider'; -import type { IChatProject, IConversation, IMessage } from '../types'; +import type { IChatProject, IConversation, IMessage, ISharedLink } from '../types'; import { ConversationMethods, createConversationMethods } from './conversation'; import { tenantStorage, runAsSystem } from '~/config/tenantContext'; import { createModels } from '../models'; @@ -984,6 +984,35 @@ describe('Conversation Operations', () => { const reloaded = await Message().findById(lateMessage._id).lean(); expect(reloaded?.expiredAt ?? null).toBeNull(); }); + + it('caps an existing permanent shared link when ephemeral converts a conversation', async () => { + const SharedLink = mongoose.models.SharedLink as mongoose.Model; + await SharedLink.deleteMany({}); + const conversationId = uuidv4(); + 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 saveConvo( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { conversationId, isArchived: true }, + ); + + const reloaded = await SharedLink.findOne({ conversationId }).lean(); + expect(reloaded?.expiredAt).toBeInstanceOf(Date); + }); }); describe('searchConversation', () => { diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 185ada376b..eba2c7b72f 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -1,10 +1,17 @@ import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider'; import type { FilterQuery, Model, SortOrder } from 'mongoose'; import type { DeleteResult } from 'mongoose'; -import type { AppConfig, IChatProjectDocument, IConversation, IMessage } from '~/types'; +import type { + AppConfig, + IChatProjectDocument, + IConversation, + IMessage, + ISharedLink, +} from '~/types'; import type { MessageMethods } from './message'; import { buildRetentionVisibilityFilter, + capConversationSharedLinks, conversationNeedsForcedRetention, createFallbackRetentionDate, forceConversationMessagesTemporary, @@ -350,7 +357,9 @@ export function createConversationMethods( conversationNeedsForcedRetention(parentRetention, forcedExpiredAt) ) { const Message = mongoose.models.Message as Model; + const SharedLink = mongoose.models.SharedLink as Model; await forceConversationMessagesTemporary(Message, userId, conversationId, forcedExpiredAt); + await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt); } if ( diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 61f58d90a8..b57ddd81b3 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -2,7 +2,7 @@ import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; import { RetentionMode } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import type { IConversation, IMessage } from '..'; +import type { IConversation, IMessage, ISharedLink } from '..'; import { tenantStorage, runAsSystem } from '~/config/tenantContext'; import { createMessageMethods } from './message'; import { createModels } from '../models'; @@ -978,6 +978,31 @@ describe('Message Operations', () => { 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', capExpiryToConversation: true }, + ); + + const reloaded = await SharedLink.findOne({ conversationId }).lean(); + expect(reloaded?.expiredAt).toBeInstanceOf(Date); + }); }); describe('applyForcedRetention', () => { diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index fb8818fdb8..412d326de6 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -1,6 +1,6 @@ import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider'; import type { DeleteResult, FilterQuery, Model } from 'mongoose'; -import type { AppConfig, IConversation, IMessage } from '~/types'; +import type { AppConfig, IConversation, IMessage, ISharedLink } from '~/types'; import { capForcedRetentionToParent, cascadeForcedConversationRetention, @@ -167,9 +167,11 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa metadata?.capExpiryToConversation === true ) { const Conversation = mongoose.models.Conversation as Model; + const SharedLink = mongoose.models.SharedLink as Model; update.expiredAt = await capForcedRetentionToParent( Conversation, Message, + SharedLink, userId, conversationId, forcedExpiredAt, @@ -197,9 +199,11 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa if (isForcedRetention && forcedExpiredAt instanceof Date) { const Conversation = mongoose.models.Conversation as Model; + const SharedLink = mongoose.models.SharedLink as Model; await cascadeForcedConversationRetention( Conversation, Message, + SharedLink, userId, conversationId, forcedExpiredAt, @@ -384,11 +388,13 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa const Message = mongoose.models.Message as Model; const Conversation = mongoose.models.Conversation as Model; + const SharedLink = mongoose.models.SharedLink as Model; if (metadata?.capExpiryToConversation === true) { forcedExpiredAt = await capForcedRetentionToParent( Conversation, Message, + SharedLink, userId, conversationId, forcedExpiredAt, @@ -402,6 +408,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa await cascadeForcedConversationRetention( Conversation, Message, + SharedLink, userId, conversationId, forcedExpiredAt, diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index 512d6894fd..ce96414049 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -1,5 +1,5 @@ import type { FilterQuery, Model } from 'mongoose'; -import type { IConversation, IMessage } from '~/types'; +import type { IConversation, IMessage, ISharedLink } from '~/types'; import { DEFAULT_RETENTION_HOURS } from './tempChatRetention'; export type RetentionFilterDocument = { @@ -91,6 +91,29 @@ export const forceConversationMessagesTemporary = async ( ); }; +/** + * Caps a conversation's shared links to the forced deadline. A share embeds a snapshot of + * the conversation (message refs and file snapshots) and its TTL index keys off `expiredAt` + * alone, so a permanent share (`expiredAt: null`) created before forced retention would stay + * publicly readable after the conversation and messages expire. Only links with no + * expiration or a later one are touched, so it is a no-op once a conversation conforms. + */ +export const capConversationSharedLinks = async ( + SharedLink: Model, + userId: string, + conversationId: string, + forcedExpiredAt: Date, +): Promise => { + await SharedLink.updateMany( + { + conversationId, + user: userId, + $or: [{ expiredAt: null }, { expiredAt: { $gt: forcedExpiredAt } }], + }, + { $set: { expiredAt: forcedExpiredAt } }, + ); +}; + /** * Caps a message-only forced save to a parent that already expires sooner than the freshly * computed window. Returns the parent's earlier deadline (so the message cannot outlive it) @@ -102,6 +125,7 @@ export const forceConversationMessagesTemporary = async ( export const capForcedRetentionToParent = async ( Conversation: Model, Message: Model, + SharedLink: Model, userId: string, conversationId: string, forcedExpiredAt: Date, @@ -116,6 +140,7 @@ export const capForcedRetentionToParent = async ( parent.expiredAt.getTime() < forcedExpiredAt.getTime() ) { await forceConversationMessagesTemporary(Message, userId, conversationId, parent.expiredAt); + await capConversationSharedLinks(SharedLink, userId, conversationId, parent.expiredAt); return parent.expiredAt; } return forcedExpiredAt; @@ -130,6 +155,7 @@ export const capForcedRetentionToParent = async ( export const cascadeForcedConversationRetention = async ( Conversation: Model, Message: Model, + SharedLink: Model, userId: string, conversationId: string, forcedExpiredAt: Date, @@ -140,5 +166,6 @@ export const cascadeForcedConversationRetention = async ( ); if (convoResult.modifiedCount > 0) { await forceConversationMessagesTemporary(Message, userId, conversationId, forcedExpiredAt); + await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt); } };