fix: cap existing shared links when forced retention converts a conversation

A SharedLink embeds a snapshot of the conversation (message refs and file
snapshots) and its TTL index keys off expiredAt alone, while shared-link reads
use activeExpirationFilter. So a permanent share (expiredAt null) created before
an install switched to ephemeral stayed publicly readable indefinitely after the
forced-temporary conversation and messages TTL out.

Add capConversationSharedLinks and call it wherever forced retention converts a
conversation - saveConvo's backfill, the shared cascade, and the cap-to-parent
path - so existing shares with no expiration or a later one are capped to the
forced deadline and expire with the conversation.
This commit is contained in:
Marco Beretta 2026-06-23 01:21:08 +02:00
parent da5b1e222b
commit 13d677c4ac
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
5 changed files with 102 additions and 5 deletions

View file

@ -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<ISharedLink>;
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', () => {

View file

@ -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<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
await forceConversationMessagesTemporary(Message, userId, conversationId, forcedExpiredAt);
await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt);
}
if (

View file

@ -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<ISharedLink>;
await SharedLink.deleteMany({});
const conversationId = uuidv4();
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Existing permanent chat',
});
await SharedLink.create({ conversationId, user: 'user123', shareId: uuidv4() });
await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
{ context: 'branch', capExpiryToConversation: true },
);
const reloaded = await SharedLink.findOne({ conversationId }).lean();
expect(reloaded?.expiredAt).toBeInstanceOf(Date);
});
});
describe('applyForcedRetention', () => {

View file

@ -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<IConversation>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
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<IConversation>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
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<IMessage>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
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,

View file

@ -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<ISharedLink>,
userId: string,
conversationId: string,
forcedExpiredAt: Date,
): Promise<void> => {
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<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
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<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
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);
}
};