fix: enforce forced retention on message edits, feedback, and error saves

Two more message-write paths bypassed ephemeral enforcement:

- The edit and feedback endpoints call updateMessage directly, without loading
  retention config, so editing an older permanent message after a switch to
  ephemeral left the message and its conversation non-temporary and visible.
  Load config on those routes and run a new applyForcedRetention helper after the
  update, which stamps the message and cascades the conversation/messages.

- The sendError and denyRequest middleware save messages with retention config
  but never call saveConvo, so a validation/model error or denied-request message
  could outlive its conversation. Pass capExpiryToConversation like the other
  message-only paths.

Extract the conversation cascade into a shared cascadeForcedConversationRetention
helper used by both saveMessage and applyForcedRetention.
This commit is contained in:
Marco Beretta 2026-06-22 10:46:04 +02:00
parent 6ab4400ace
commit 9cbbeb6e85
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
6 changed files with 290 additions and 112 deletions

View file

@ -49,7 +49,10 @@ const denyRequest = async (req, res, errorMessage) => {
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...userMessage, user: req.user.id },
{ context: `api/server/middleware/denyRequest.js - ${responseText}` },
{
context: `api/server/middleware/denyRequest.js - ${responseText}`,
capExpiryToConversation: true,
},
);
}

View file

@ -56,6 +56,7 @@ const sendError = async (req, res, options, callback) => {
{ ...errorMessage, user },
{
context: 'api/server/utils/streamResponse.js - sendError',
capExpiryToConversation: true,
},
);
}

View file

@ -15,6 +15,17 @@ const db = require('~/models');
const router = express.Router();
router.use(requireJwtAuth);
/**
* Enforces forced (ephemeral) retention after a message-only update (edit/feedback),
* which bypasses the saveMessage/saveConvo enforcement. No-op outside forced retention.
*/
const enforceForcedRetention = (req, conversationId, messageId, context) =>
db.applyForcedRetention(
{ userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig },
{ conversationId, messageId },
{ context, capExpiryToConversation: true },
);
router.get('/', async (req, res) => {
try {
const user = req.user.id ?? '';
@ -323,108 +334,131 @@ router.get('/:conversationId/:messageId', validateMessageReq, async (req, res) =
}
});
router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { text, index, model } = req.body;
router.put(
'/:conversationId/:messageId',
validateMessageReq,
configMiddleware,
async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { text, index, model } = req.body;
if (index === undefined) {
const tokenCount = await countTokens(text, model);
const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount });
return res.status(200).json(result);
}
if (index === undefined) {
const tokenCount = await countTokens(text, model);
const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount });
await enforceForcedRetention(
req,
conversationId,
messageId,
'PUT /api/messages - edit text',
);
return res.status(200).json(result);
}
if (typeof index !== 'number' || index < 0) {
return res.status(400).json({ error: 'Invalid index' });
}
if (typeof index !== 'number' || index < 0) {
return res.status(400).json({ error: 'Invalid index' });
}
const message = (
await db.getMessages({ conversationId, messageId, user: req.user.id }, 'content tokenCount')
)?.[0];
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
const message = (
await db.getMessages({ conversationId, messageId, user: req.user.id }, 'content tokenCount')
)?.[0];
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
const existingContent = message.content;
if (!Array.isArray(existingContent) || index >= existingContent.length) {
return res.status(400).json({ error: 'Invalid index' });
}
const existingContent = message.content;
if (!Array.isArray(existingContent) || index >= existingContent.length) {
return res.status(400).json({ error: 'Invalid index' });
}
const updatedContent = [...existingContent];
if (!updatedContent[index]) {
return res.status(400).json({ error: 'Content part not found' });
}
const updatedContent = [...existingContent];
if (!updatedContent[index]) {
return res.status(400).json({ error: 'Content part not found' });
}
const currentPartType = updatedContent[index].type;
if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
return res.status(400).json({ error: 'Cannot update non-text content' });
}
const currentPartType = updatedContent[index].type;
if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
return res.status(400).json({ error: 'Cannot update non-text content' });
}
const oldText = updatedContent[index][currentPartType];
updatedContent[index] = { type: currentPartType, [currentPartType]: text };
const oldText = updatedContent[index][currentPartType];
updatedContent[index] = { type: currentPartType, [currentPartType]: text };
let tokenCount = message.tokenCount;
if (tokenCount !== undefined) {
const oldTokenCount = await countTokens(oldText, model);
const newTokenCount = await countTokens(text, model);
tokenCount = Math.max(0, tokenCount - oldTokenCount) + newTokenCount;
}
let tokenCount = message.tokenCount;
if (tokenCount !== undefined) {
const oldTokenCount = await countTokens(oldText, model);
const newTokenCount = await countTokens(text, model);
tokenCount = Math.max(0, tokenCount - oldTokenCount) + newTokenCount;
}
const result = await db.updateMessage(req?.user?.id, {
messageId,
content: updatedContent,
tokenCount,
});
return res.status(200).json(result);
} catch (error) {
logger.error('Error updating message:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { feedback } = req.body;
const updatedMessage = await db.updateMessage(
req?.user?.id,
{
const result = await db.updateMessage(req?.user?.id, {
messageId,
feedback: feedback || null,
},
{ context: 'updateFeedback' },
);
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
sendFeedbackScore({
traceId: traceIdForMessage(messageId),
feedback: updatedMessage.feedback,
metadata: {
messageId: updatedMessage.messageId ?? messageId,
parentMessageId: updatedMessage.parentMessageId,
conversationId: updatedMessage.conversationId ?? conversationId,
sessionId: updatedMessage.conversationId ?? conversationId,
userId: req?.user?.id,
endpoint: updatedMessage.endpoint,
sender: updatedMessage.sender,
isCreatedByUser: updatedMessage.isCreatedByUser,
tokenCount: updatedMessage.tokenCount,
},
}).catch((err) => logger.error('[langfuse] feedback score failed:', err));
content: updatedContent,
tokenCount,
});
await enforceForcedRetention(
req,
conversationId,
messageId,
'PUT /api/messages - edit content',
);
return res.status(200).json(result);
} catch (error) {
logger.error('Error updating message:', error);
res.status(500).json({ error: 'Internal server error' });
}
},
);
res.json({
messageId,
conversationId,
feedback: updatedMessage.feedback,
});
} catch (error) {
logger.error('Error updating message feedback:', error);
res.status(500).json({ error: 'Failed to update feedback' });
}
});
router.put(
'/:conversationId/:messageId/feedback',
validateMessageReq,
configMiddleware,
async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { feedback } = req.body;
const updatedMessage = await db.updateMessage(
req?.user?.id,
{
messageId,
feedback: feedback || null,
},
{ context: 'updateFeedback' },
);
await enforceForcedRetention(req, conversationId, messageId, 'PUT /api/messages - feedback');
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
sendFeedbackScore({
traceId: traceIdForMessage(messageId),
feedback: updatedMessage.feedback,
metadata: {
messageId: updatedMessage.messageId ?? messageId,
parentMessageId: updatedMessage.parentMessageId,
conversationId: updatedMessage.conversationId ?? conversationId,
sessionId: updatedMessage.conversationId ?? conversationId,
userId: req?.user?.id,
endpoint: updatedMessage.endpoint,
sender: updatedMessage.sender,
isCreatedByUser: updatedMessage.isCreatedByUser,
tokenCount: updatedMessage.tokenCount,
},
}).catch((err) => logger.error('[langfuse] feedback score failed:', err));
}
res.json({
messageId,
conversationId,
feedback: updatedMessage.feedback,
});
} catch (error) {
logger.error('Error updating message feedback:', error);
res.status(500).json({ error: 'Failed to update feedback' });
}
},
);
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
try {