LibreChat/api/server/middleware/validate/convoAccess.js
Danny Avila f3c6e24f84
perf: Cut Serial Round Trips and a 100ms Admission Stall from Chat Turns (#15138)
*  perf: Stop Awaiting the Conversation Access Marker Write

Without Redis the CONVO_ACCESS violations namespace is backed by keyv-file,
whose debounced write resolves after ~100ms. validateConvoAccess awaited
that write before calling next(), so the first message to any existing
conversation waited ~100ms before the request was even admitted — once
per conversation per ten-minute window, on every default deployment.

The marker only short-circuits the next check, so the write no longer
gates the request. The same read now stashes the full document on
req.resolvedConversation (null when absent) for downstream consumers.

First-turn ack on an existing conversation: 109ms -> 5ms.

*  perf: Read the Conversation Once per Chat Turn

A chat turn read the same conversation document four times: the access
check (two fields), the subagent thread guard (full document), agent
initialization (the files field), and the first save. The access check
now reads the full document and leaves it on req.resolvedConversation,
the guard accepts that pre-resolved document instead of re-reading, and
initializeAgent takes the conversation's file refs from it rather than
issuing a separate findOne.

Two serial round trips removed from every turn; the same document still
serves the first save as before.

*  perf: Remove Duplicate JWT Authentication on Agents Routes

routes/agents/index.js applies requireJwtAuth and then mounts the v1
router at '/', which applied requireJwtAuth again. Every request through
the agents router — chat turns included — ran the passport strategy
twice: two signature checks and two user document reads. The v1 router
is mounted nowhere else; its separately exported avatar router carries
its own auth in files/index.js.

*  perf: Skip the History Read for Root-Parent Turns and Walk the Tree in O(n)

loadHistory fetched every message in the conversation and then walked the
parent chain from the request's head. For a new conversation — or a new
branch from the root of an existing one — the head is the root sentinel,
which no message carries as its id, so the walk was empty by construction
and the fetch was wasted. It now returns early.

getMessagesForConversation found each ancestor with Array.find inside the
walk, O(n^2) on a linear conversation (~5ms at 1000 messages). A Map by
messageId makes it O(n); first-match semantics are preserved.
2026-08-23 15:17:35 -04:00

92 lines
3.4 KiB
JavaScript

const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
const denyRequest = require('~/server/middleware/denyRequest');
const { logViolation, getLogStores } = require('~/cache');
const { searchConversation } = require('~/models');
const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {};
/**
* Helper function to get conversationId from different request body structures.
* @param {Object} body - The request body.
* @returns {string|undefined} The conversationId.
*/
const getConversationId = (body) => {
return body.conversationId ?? body.arg?.conversationId;
};
/**
* Middleware to validate user's authorization for a conversation.
*
* This middleware checks if a user has the right to access a specific conversation.
* If the user doesn't have access, an error is returned. If the conversation doesn't exist,
* a not found error is returned. If the access is valid, the middleware allows the request to proceed.
* If the `cache` store is not available, the middleware will skip its logic.
*
* @function
* @param {ServerRequest} req - Express request object containing user information.
* @param {Express.Response} res - Express response object.
* @param {function} next - Express next middleware function.
* @throws {Error} Throws an error if the user doesn't have access to the conversation.
*/
const validateConvoAccess = async (req, res, next) => {
const namespace = ViolationTypes.CONVO_ACCESS;
const cache = getLogStores(namespace);
const conversationId = getConversationId(req.body);
if (!conversationId || conversationId === Constants.NEW_CONVO) {
return next();
}
const userId = req.user?.id ?? req.user?._id ?? '';
const type = ViolationTypes.CONVO_ACCESS;
const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}:${conversationId}`;
try {
if (cache) {
const cachedAccess = await cache.get(key);
if (cachedAccess === 'authorized') {
return next();
}
}
/** One read serves the subagent guard, agent initialization, and the first save via
* `req.resolvedConversation`. `messages` is the only unbounded field and no consumer
* reads it, so it stays excluded — ownership is not yet known at this point. */
const conversation = await searchConversation(conversationId, '-messages');
if (!conversation) {
req.resolvedConversation = null;
return next();
}
if (conversation.user !== userId) {
const errorMessage = {
type,
error: 'User not authorized for this conversation',
};
if (cache) {
await logViolation(req, res, type, errorMessage, score);
}
return await denyRequest(req, res, errorMessage);
}
if (cache) {
/** The marker only short-circuits the next check; the violations store is file-backed
* without Redis and its debounced write takes ~100ms, so it must not gate this request. */
cache.set(key, 'authorized', Time.TEN_MINUTES).catch((error) => {
logger.warn('[validateConvoAccess] Failed to cache conversation access', error);
});
}
req.resolvedConversation = conversation;
next();
} catch (error) {
console.error('Error validating conversation access:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
module.exports = validateConvoAccess;