From 955f41c4edfcb3f18a023ffe492a81f8f5aba374 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:59:42 +0200 Subject: [PATCH] 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. --- api/server/routes/tags.js | 26 ++++++++++++-- .../data-schemas/src/methods/message.spec.ts | 34 +++++++++++++++++++ packages/data-schemas/src/methods/message.ts | 23 +++++++------ 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/api/server/routes/tags.js b/api/server/routes/tags.js index a1fa1f77bb..3e78272985 100644 --- a/api/server/routes/tags.js +++ b/api/server/routes/tags.js @@ -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); diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index b57ddd81b3..e60d353156 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -1047,6 +1047,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(); diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 412d326de6..36e64b53cc 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -44,7 +44,7 @@ export interface MessageMethods { ): Promise>; applyForcedRetention( ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] }, - params: { conversationId: string; messageId: string }, + params: { conversationId: string; messageId?: string }, metadata?: { context?: string; capExpiryToConversation?: boolean }, ): Promise; deleteMessagesSince( @@ -363,14 +363,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 { if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) { @@ -401,10 +402,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,