LibreChat/api/server/middleware/validateMessageReq.js
Danny Avila c8abd826e1 🛡️ fix: Address Codex round 4 — paused-job edge cases across the stack
Five P2 findings on 4324a4e776, all valid:

- I1 message validation: validateMessageReq's active-job read bypass now
  accepts a live requires_action job, so a new-conversation run that pauses
  before its final save can recover the prompt instead of 404ing.
- I2 expire targets the observed record: resolve()'s expired path passes
  `expectedActionId ?? job.pendingAction.actionId`, so a concurrent
  resume+re-pause can't let expire abort a different action.
- I3 stale/malformed prompts: new isPendingActionStale (missing OR expired)
  drives active-listing exclusion + cleanup expiry in both stores, and the
  status route + middleware require a live pendingAction — a requires_action
  job whose pendingAction was dropped on deserialize no longer reads active.
- I4 in-memory parity: InMemory updateJob mirrors pendingActionId on pause and
  clears it + refreshes lastActiveAt on resume (matching RedisJobStore), so a
  pause via the generic path is still resolvable by actionId.
- I5 long approval windows: paused-job live TTL (job/chunks/run-steps) now
  covers pendingAction.expiresAt + grace (pauseTtlSeconds), on both the
  transitionStatus and updateJob pause paths, so Redis can't evict a paused
  job before its decision window closes.

tsc + lint clean; policy + type-contract specs pass.
2026-06-16 14:51:49 -04:00

78 lines
2.7 KiB
JavaScript

const { GenerationJobManager } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getConvo } = require('~/models');
function hasTenantMismatch(job, user) {
// Untenanted jobs remain readable by their owner for pre-multi-tenancy deployments.
return job.metadata?.tenantId != null && job.metadata.tenantId !== user.tenantId;
}
async function canReadActiveJobConversation(req, conversationId) {
if (req.method !== 'GET' || req.params?.messageId) {
return false;
}
let job;
try {
job = await GenerationJobManager.getJob(conversationId);
} catch (error) {
logger.warn(`[validateMessageReq] Active job lookup failed for ${conversationId}:`, error);
return false;
}
// A job paused for human review is still active (consistent with /chat/status
// and /chat/active), so a new-conversation run that pauses before its final
// save can still recover the prompt — but only while it has a live,
// resolvable prompt (missing/malformed or past-expiry reads as inactive).
const pendingAction = job?.metadata?.pendingAction;
const pendingLive =
!!pendingAction && (pendingAction.expiresAt == null || pendingAction.expiresAt > Date.now());
const isActive =
!!job && (job.status === 'running' || (job.status === 'requires_action' && pendingLive));
if (!isActive) {
return false;
}
return job.metadata?.userId === req.user.id && !hasTenantMismatch(job, req.user);
}
// Middleware to validate conversationId and user relationship
const validateMessageReq = async (req, res, next) => {
const body = req.body ?? {};
const paramConversationId = req.params?.conversationId;
const bodyConversationId = body.conversationId;
const nestedConversationId = body.message?.conversationId;
if (
(paramConversationId &&
((bodyConversationId && paramConversationId !== bodyConversationId) ||
(nestedConversationId && paramConversationId !== nestedConversationId))) ||
(bodyConversationId && nestedConversationId && bodyConversationId !== nestedConversationId)
) {
return res.status(400).json({ error: 'Conversation ID mismatch' });
}
const conversationId = paramConversationId || bodyConversationId || nestedConversationId;
if (conversationId === 'new') {
return res.status(200).send([]);
}
const conversation = await getConvo(req.user.id, conversationId);
if (!conversation) {
if (await canReadActiveJobConversation(req, conversationId)) {
return next();
}
return res.status(404).json({ error: 'Conversation not found' });
}
if (conversation.user !== req.user.id) {
return res.status(403).json({ error: 'User not authorized for this conversation' });
}
next();
};
module.exports = validateMessageReq;