fix: enforce forced retention on global tag rename and delete

Per-conversation tag writes already convert their conversation under
forced ephemeral retention, but global bookmark-tag renames and deletes
rewrite conversation rows via Conversation.updateMany without setting
isTemporary/expiredAt. A permanent chat tagged before the install
switched to ephemeral, touched only by a tag rename or delete, would
keep its retention fields unset and stay visible and non-expiring.

Add cascadeForcedRetentionByTag plus an applyForcedRetentionToTag method
that bulk-converts every conversation carrying a tag (and backfills its
messages and caps its shares) through the shared gap filter, so it never
extends a chat that already expires sooner. Load the interface config on
PUT /:tag and DELETE /:tag and route those writes through it.
This commit is contained in:
Marco Beretta 2026-06-24 15:38:10 +02:00
parent 339520fb90
commit e764c983cf
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 210 additions and 2 deletions

View file

@ -9,6 +9,7 @@ const {
deleteConversationTag,
getConversationTags,
applyForcedRetention,
applyForcedRetentionToTag,
getRoleByName,
} = require('~/models');
const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
@ -35,6 +36,18 @@ const enforceForcedRetention = (req, conversationId, context) =>
{ context },
);
/**
* 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 = (req, tag, context) =>
applyForcedRetentionToTag(
{ userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig },
{ tag },
{ context },
);
/**
* GET /
* Retrieves all conversation tags for the authenticated user.
@ -80,11 +93,12 @@ router.post('/', configMiddleware, async (req, res) => {
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.put('/:tag', async (req, res) => {
router.put('/:tag', configMiddleware, async (req, res) => {
try {
const decodedTag = decodeURIComponent(req.params.tag);
const tag = await updateConversationTag(req.user.id, decodedTag, req.body);
if (tag) {
await enforceForcedRetentionForTag(req, req.body?.tag || decodedTag, 'PUT /api/tags/:tag');
res.status(200).json(tag);
} else {
res.status(404).json({ error: 'Tag not found' });
@ -101,9 +115,10 @@ router.put('/:tag', async (req, res) => {
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.delete('/:tag', async (req, res) => {
router.delete('/:tag', configMiddleware, 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);

View file

@ -23,6 +23,7 @@ let saveMessage: ReturnType<typeof createMessageMethods>['saveMessage'];
let getMessages: ReturnType<typeof createMessageMethods>['getMessages'];
let updateMessage: ReturnType<typeof createMessageMethods>['updateMessage'];
let applyForcedRetention: ReturnType<typeof createMessageMethods>['applyForcedRetention'];
let applyForcedRetentionToTag: ReturnType<typeof createMessageMethods>['applyForcedRetentionToTag'];
let deleteMessages: ReturnType<typeof createMessageMethods>['deleteMessages'];
let bulkSaveMessages: ReturnType<typeof createMessageMethods>['bulkSaveMessages'];
let updateMessageText: ReturnType<typeof createMessageMethods>['updateMessageText'];
@ -42,6 +43,7 @@ beforeAll(async () => {
getMessages = methods.getMessages;
updateMessage = methods.updateMessage;
applyForcedRetention = methods.applyForcedRetention;
applyForcedRetentionToTag = methods.applyForcedRetentionToTag;
deleteMessages = methods.deleteMessages;
bulkSaveMessages = methods.bulkSaveMessages;
updateMessageText = methods.updateMessageText;
@ -1149,6 +1151,101 @@ describe('Message Operations', () => {
});
});
describe('applyForcedRetentionToTag', () => {
const Conversation = () => mongoose.models.Conversation as mongoose.Model<IConversation>;
beforeEach(async () => {
await Conversation().deleteMany({});
});
it('converts every permanent conversation carrying the tag under ephemeral mode', async () => {
const taggedA = uuidv4();
const taggedB = uuidv4();
const untagged = uuidv4();
await Conversation().create([
{ conversationId: taggedA, user: 'user123', endpoint: 'openAI', tags: ['work'] },
{ conversationId: taggedB, user: 'user123', endpoint: 'openAI', tags: ['work', 'urgent'] },
{ conversationId: untagged, user: 'user123', endpoint: 'openAI', tags: ['personal'] },
]);
await Message.create([
{ messageId: uuidv4(), conversationId: taggedA, user: 'user123', text: 'a' },
{ messageId: uuidv4(), conversationId: taggedB, user: 'user123', text: 'b' },
{ messageId: uuidv4(), conversationId: untagged, user: 'user123', text: 'c' },
]);
await applyForcedRetentionToTag(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ tag: 'work' },
{ context: 'DELETE /api/tags/:tag' },
);
for (const conversationId of [taggedA, taggedB]) {
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.isTemporary).toBe(true);
expect(convo?.expiredAt).toBeInstanceOf(Date);
const messages = await getMessages({ conversationId, user: 'user123' });
for (const message of messages) {
expect(message.isTemporary).toBe(true);
expect(message.expiredAt).toBeInstanceOf(Date);
}
}
const untouched = await Conversation().findOne({ conversationId: untagged }).lean();
expect(untouched?.isTemporary ?? null).not.toBe(true);
expect(untouched?.expiredAt ?? null).toBeNull();
});
it('does not extend a tagged conversation that already expires sooner', async () => {
const conversationId = uuidv4();
const soonerExpiry = new Date(Date.now() + 60 * 60 * 1000);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
tags: ['work'],
isTemporary: true,
expiredAt: soonerExpiry,
});
await applyForcedRetentionToTag(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ tag: 'work' },
{ context: 'PUT /api/tags/:tag' },
);
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
});
it('is a no-op outside forced retention', async () => {
const conversationId = uuidv4();
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
tags: ['work'],
});
await applyForcedRetentionToTag(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.ALL },
},
{ tag: 'work' },
{ context: 'PUT /api/tags/:tag' },
);
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.expiredAt ?? null).toBeNull();
});
});
describe('Message cursor pagination', () => {
/**
* Helper to create messages with specific timestamps

View file

@ -4,6 +4,7 @@ import type { AppConfig, IConversation, IMessage, ISharedLink } from '~/types';
import {
capForcedRetentionToParent,
cascadeForcedConversationRetention,
cascadeForcedRetentionByTag,
createFallbackRetentionDate,
} from '~/utils/retention';
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
@ -47,6 +48,11 @@ export interface MessageMethods {
params: { conversationId: string; messageId?: string },
metadata?: { context?: string; capExpiryToConversation?: boolean },
): Promise<void>;
applyForcedRetentionToTag(
ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
params: { tag: string },
metadata?: { context?: string },
): Promise<void>;
deleteMessagesSince(
userId: string,
params: { messageId: string; conversationId: string },
@ -419,6 +425,45 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
);
}
/**
* Enforces forced (ephemeral) retention on every conversation carrying a bookmark tag,
* for tag-scoped writes that bypass `saveConvo`/`applyForcedRetention` global tag renames
* and deletes that `Conversation.updateMany` the tag on/off existing chats. Without this an
* older permanent chat touched only by a tag change after an install switches to ephemeral
* would stay visible and never expire. A no-op outside forced retention.
*/
async function applyForcedRetentionToTag(
{ userId, interfaceConfig }: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
{ tag }: { tag: string },
metadata?: { context?: string },
): Promise<void> {
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
return;
}
let forcedExpiredAt: Date;
try {
forcedExpiredAt = createTempChatExpirationDate(interfaceConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`applyForcedRetentionToTag\` context: ${metadata?.context}`);
forcedExpiredAt = createFallbackRetentionDate();
}
const Message = mongoose.models.Message as Model<IMessage>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
await cascadeForcedRetentionByTag(
Conversation,
Message,
SharedLink,
userId,
tag,
forcedExpiredAt,
);
}
/**
* Deletes messages in a conversation since a specific message.
*/
@ -556,6 +601,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
updateMessageText,
updateMessage,
applyForcedRetention,
applyForcedRetentionToTag,
deleteMessagesSince,
getMessages,
getMessage,

View file

@ -168,3 +168,53 @@ export const cascadeForcedConversationRetention = async (
await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt);
}
};
/**
* Bulk-applies forced retention to every conversation carrying a bookmark tag. A tag rename
* or delete writes conversation rows directly (`Conversation.updateMany`) without setting
* `isTemporary`/`expiredAt`, so a permanent chat tagged before the install switched to
* ephemeral would otherwise stay visible and never expire. One pass converts the chats,
* backfills their messages, and caps their shares; the gap filter keeps it a no-op for chats
* that already conform and never extends a chat that already expires sooner.
*/
export const cascadeForcedRetentionByTag = async (
Conversation: Model<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
userId: string,
tag: string,
forcedExpiredAt: Date,
): Promise<void> => {
const taggedConversations = await Conversation.find(
{ user: userId, tags: tag },
'conversationId',
).lean<Array<{ conversationId: string }>>();
if (taggedConversations.length === 0) {
return;
}
const conversationIds = taggedConversations.map((convo) => convo.conversationId);
await Conversation.updateMany(
{
user: userId,
conversationId: { $in: conversationIds },
...forcedRetentionGapFilter<IConversation>(forcedExpiredAt),
},
{ $set: { isTemporary: true, expiredAt: forcedExpiredAt } },
);
await Message.updateMany(
{
user: userId,
conversationId: { $in: conversationIds },
...forcedRetentionGapFilter<IMessage>(forcedExpiredAt),
},
{ $set: { isTemporary: true, expiredAt: forcedExpiredAt } },
);
await SharedLink.updateMany(
{
user: userId,
conversationId: { $in: conversationIds },
$or: [{ expiredAt: null }, { expiredAt: { $gt: forcedExpiredAt } }],
},
{ $set: { expiredAt: forcedExpiredAt } },
);
};