fix: enforce forced retention on per-conversation tag writes

Bookmark-tag writes update Conversation rows directly without saveConvo, so under
ephemeral retention adding a tag to a chat (createConversationTag) or changing a
chat's tag list (updateTagsForConversation) left an older permanent conversation
with isTemporary/expiredAt unset, keeping it visible and non-expiring.

Make applyForcedRetention's messageId optional so it can run the conversation
cascade alone, load app config on the per-conversation tag routes (POST /api/tags
when adding to a conversation, PUT /api/tags/convo/:conversationId), and enforce
retention after the write.
This commit is contained in:
Marco Beretta 2026-06-23 08:59:42 +02:00
parent 28a98c744c
commit fde5b92248
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 70 additions and 13 deletions

View file

@ -8,9 +8,10 @@ const {
createConversationTag,
deleteConversationTag,
getConversationTags,
applyForcedRetention,
getRoleByName,
} = require('~/models');
const { requireJwtAuth } = require('~/server/middleware');
const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
const router = express.Router();
@ -23,6 +24,17 @@ 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, context) =>
applyForcedRetention(
{ userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig },
{ conversationId },
{ context },
);
/**
* GET /
* Retrieves all conversation tags for the authenticated user.
@ -49,9 +61,12 @@ router.get('/', async (req, res) => {
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.post('/', async (req, res) => {
router.post('/', configMiddleware, 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, 'POST /api/tags');
}
res.status(200).json(tag);
} catch (error) {
logger.error('Error creating conversation tag:', error);
@ -107,13 +122,18 @@ router.delete('/:tag', async (req, res) => {
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.put('/convo/:conversationId', async (req, res) => {
router.put('/convo/:conversationId', configMiddleware, async (req, res) => {
try {
const conversationTags = await updateTagsForConversation(
req.user.id,
req.params.conversationId,
req.body.tags,
);
await enforceForcedRetention(
req,
req.params.conversationId,
'PUT /api/tags/convo/:conversationId',
);
res.status(200).json(conversationTags);
} catch (error) {
logger.error('Error updating conversation tags', error);

View file

@ -1302,6 +1302,40 @@ describe('Message Operations', () => {
}
});
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(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId },
{ context: 'tag' },
);
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();

View file

@ -53,7 +53,7 @@ export interface MessageMethods {
): Promise<Partial<IMessage>>;
applyForcedRetention(
ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
params: { conversationId: string; messageId: string },
params: { conversationId: string; messageId?: string },
metadata?: { context?: string; capExpiryToConversation?: boolean },
): Promise<void>;
deleteMessagesSince(
@ -524,14 +524,15 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
}
/**
* Enforces forced (ephemeral) retention on an existing message and its parent
* conversation. Message-write paths that only update a row edits, feedback bypass
* the `saveMessage`/`saveConvo` enforcement, so an older permanent chat touched after an
* install switches to ephemeral would otherwise stay visible and never expire.
* Enforces forced (ephemeral) retention on a conversation (and optionally a specific
* message) that was touched outside `saveMessage`/`saveConvo` message edits, feedback,
* or bookmark-tag writes. Without these, an older permanent chat touched after an install
* switches to ephemeral would stay visible and never expire. Omit `messageId` for
* conversation-only writes (e.g. tag changes) to run just the conversation cascade.
*/
async function applyForcedRetention(
{ userId, interfaceConfig }: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
{ conversationId, messageId }: { conversationId: string; messageId: string },
{ conversationId, messageId }: { conversationId: string; messageId?: string },
metadata?: { context?: string; capExpiryToConversation?: boolean },
): Promise<void> {
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
@ -562,10 +563,12 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
);
}
await Message.updateOne(
{ messageId, user: userId },
{ $set: { isTemporary: true, expiredAt: forcedExpiredAt } },
);
if (messageId) {
await Message.updateOne(
{ messageId, user: userId },
{ $set: { isTemporary: true, expiredAt: forcedExpiredAt } },
);
}
await cascadeForcedConversationRetention(
Conversation,
Message,