diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js
index 4a4f6975bda..62eacccb4be 100644
--- a/api/app/clients/BaseClient.js
+++ b/api/app/clients/BaseClient.js
@@ -30,6 +30,9 @@ const {
seedTurnConversation,
needsRetentionConversation,
getConversationWriteContext,
+ savePrivateTextMessage,
+ stampPrivateTextMessage,
+ requirePrivateTextPersistence,
} = require('@librechat/api');
const {
Constants,
@@ -586,13 +589,16 @@ class BaseClient {
} = await this.setMessageOptions(opts);
this.options.startupTelemetry?.mark('history_loaded');
- const userMessage = this.resolveStartUserMessage({
- opts,
- message,
- userMessageId,
- parentMessageId,
- conversationId,
- });
+ const userMessage = stampPrivateTextMessage(
+ this.options.req,
+ this.resolveStartUserMessage({
+ opts,
+ message,
+ userMessageId,
+ parentMessageId,
+ conversationId,
+ }),
+ );
/**
* Attach quoted excerpts (the "Add to chat" selections from `req.body.quotes`)
@@ -1029,6 +1035,9 @@ class BaseClient {
await balanceReservations.track(balanceAdmission);
}
+ await requirePrivateTextPersistence(this.options.req, () =>
+ userMessagePersistence != null ? userMessagePersistence.start() : userMessagePromise,
+ );
completionResult = await this.sendCompletion(payload, opts);
} catch (error) {
if (userMessagePersistence?.isPending()) {
@@ -1349,7 +1358,9 @@ class BaseClient {
req.resolvedConversation = await db.getConvo(req.user.id, message.conversationId);
}
const reqCtx = getConversationWriteContext(req);
- const savedMessage = await db.saveMessage(
+ const savedMessage = await savePrivateTextMessage(
+ db.saveMessage,
+ req,
reqCtx,
{
...message,
diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
index 43e0f23daa6..a36aa3cb70f 100644
--- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
+++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
@@ -47,6 +47,8 @@ jest.mock('@librechat/data-schemas', () => ({
}));
jest.mock('@librechat/api', () => ({
+ savePrivateTextMessage: (save, _req, ...args) => save(...args),
+ stampPreliminaryPrivateTextMessage: (_req, message) => message,
getAgentErrorMetadata: (...args) =>
jest.requireActual('@librechat/api').getAgentErrorMetadata(...args),
sendEvent: jest.fn(),
diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
index ef482d04aec..658c543a4f6 100644
--- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
+++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
@@ -252,6 +252,8 @@ jest.mock('@librechat/data-schemas', () => ({
}));
jest.mock('@librechat/api', () => ({
+ savePrivateTextMessage: (save, _req, ...args) => save(...args),
+ stampPreliminaryPrivateTextMessage: (_req, message) => message,
getSteerRecoveryFailure: jest.requireActual(
'../../../../../packages/api/src/stream/SteerRecovery',
).getSteerRecoveryFailure,
diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js
index 7135ca4113d..be22de124b3 100644
--- a/api/server/controllers/agents/request.js
+++ b/api/server/controllers/agents/request.js
@@ -52,6 +52,8 @@ const {
resolvePersistableCodeEnvironmentDecision,
getFailedTurnTraceFields,
resolveFailedTurnContent,
+ savePrivateTextMessage,
+ stampPreliminaryPrivateTextMessage,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
const {
@@ -459,7 +461,9 @@ async function saveErrorTurn(
const iconURL = getEndpointIconURL(req, endpointOption);
if (userMessage) {
- const savedUserMessage = await saveMessage(
+ const savedUserMessage = await savePrivateTextMessage(
+ saveMessage,
+ req,
reqCtx,
{
...userMessage,
@@ -1590,10 +1594,13 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
const responseModel = getAgentResponseModel(req, endpointOption);
const preliminaryUserMessage = isCompaction
? projectCompactionAnchor({ messageId: parentMessageId, conversationId })
- : getPreliminaryUserMessage(
- { ...req.body, messageId: preallocatedUserMessageId },
- conversationId,
- req._agentEventTriggerProjection,
+ : stampPreliminaryPrivateTextMessage(
+ req,
+ getPreliminaryUserMessage(
+ { ...req.body, messageId: preallocatedUserMessageId },
+ conversationId,
+ req._agentEventTriggerProjection,
+ ),
);
const job = await GenerationJobManager.createJob(streamId, userId, conversationId, {
startupTelemetry,
@@ -2304,6 +2311,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
conversationId: userMsg.conversationId,
text: userMsg.text,
quotes: userMsg.quotes,
+ privacyRevision: userMsg.privacyRevision,
// Persist the turn's uploaded files here (authoritative job metadata) so a
// HITL resume sources them from the job, not the user DB row — which the
// approval prompt can race (the row save may still be in flight when a fast
@@ -2730,7 +2738,9 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
convoSignal.observeMessageWrite(Promise.resolve(savedUserTurn));
} else {
// Custom clients used by integrations/tests may not inherit BaseClient.
- const savedUserMessage = await saveMessage(
+ const savedUserMessage = await savePrivateTextMessage(
+ saveMessage,
+ req,
{
userId,
isTemporary:
@@ -2983,9 +2993,15 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
if (!userMessage) {
throw new Error('User message was unavailable before terminal persistence');
}
- const savedUserMessage = await saveMessage(reqCtx, userMessage, {
- context: 'api/server/controllers/agents/request.js - resumable user message',
- });
+ const savedUserMessage = await savePrivateTextMessage(
+ saveMessage,
+ req,
+ reqCtx,
+ userMessage,
+ {
+ context: 'api/server/controllers/agents/request.js - resumable user message',
+ },
+ );
if (!savedUserMessage) {
throw new Error('User message could not be persisted before terminal publication');
}
diff --git a/api/server/middleware/denyRequest.js b/api/server/middleware/denyRequest.js
index 521603a6d9c..2d45372244e 100644
--- a/api/server/middleware/denyRequest.js
+++ b/api/server/middleware/denyRequest.js
@@ -1,5 +1,10 @@
const crypto = require('crypto');
-const { sendEvent } = require('@librechat/api');
+const {
+ sendEvent,
+ rejectUnprotectedDeniedMessage,
+ stampPrivateTextMessage,
+ savePrivateTextMessage,
+} = require('@librechat/api');
const { getResponseSender, Constants } = require('librechat-data-provider');
const { sendError } = require('~/server/middleware/error');
const { saveMessage } = require('~/models');
@@ -21,6 +26,10 @@ const { saveMessage } = require('~/models');
* @throws {Error} Throws an error if there's an issue saving the message or sending the error.
*/
const denyRequest = async (req, res, errorMessage) => {
+ if (rejectUnprotectedDeniedMessage(req, res)) {
+ return;
+ }
+
let responseText = errorMessage;
if (typeof errorMessage === 'object') {
responseText = JSON.stringify(errorMessage);
@@ -28,21 +37,27 @@ const denyRequest = async (req, res, errorMessage) => {
const { messageId, conversationId: _convoId, parentMessageId, text } = req.body;
const conversationId = _convoId ?? crypto.randomUUID();
+ const shouldSaveMessage = Boolean(
+ _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT,
+ );
- const userMessage = {
- sender: 'User',
- messageId: messageId ?? crypto.randomUUID(),
- parentMessageId,
- conversationId,
- isCreatedByUser: true,
- text,
- };
- sendEvent(res, { message: userMessage, created: true });
-
- const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT;
+ const userMessage = stampPrivateTextMessage(
+ req,
+ {
+ sender: 'User',
+ messageId: messageId ?? crypto.randomUUID(),
+ parentMessageId,
+ conversationId,
+ isCreatedByUser: true,
+ text,
+ },
+ shouldSaveMessage,
+ );
if (shouldSaveMessage) {
- await saveMessage(
+ await savePrivateTextMessage(
+ saveMessage,
+ req,
{
userId: req?.user?.id,
isTemporary: req?.resolvedConversation?.isTemporary ?? req?.body?.isTemporary,
@@ -54,6 +69,7 @@ const denyRequest = async (req, res, errorMessage) => {
);
}
+ sendEvent(res, { message: userMessage, created: true });
return await sendError(req, res, {
sender: getResponseSender(req.body),
messageId: crypto.randomUUID(),
diff --git a/api/server/middleware/denyRequest.privateText.spec.js b/api/server/middleware/denyRequest.privateText.spec.js
new file mode 100644
index 00000000000..41084da62a0
--- /dev/null
+++ b/api/server/middleware/denyRequest.privateText.spec.js
@@ -0,0 +1,240 @@
+const { v4: uuidv4 } = require('uuid');
+const mockSaveMessage = jest.fn();
+const mockSendEvent = jest.fn();
+const mockSendError = jest.fn().mockResolvedValue(undefined);
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ sendEvent: (...args) => mockSendEvent(...args),
+}));
+jest.mock('~/models', () => ({
+ saveMessage: (...args) => mockSaveMessage(...args),
+}));
+jest.mock('~/server/middleware/error', () => ({
+ sendError: (...args) => mockSendError(...args),
+}));
+
+const { createPrivateTextIngress } = require('@librechat/api');
+const denyRequest = require('./denyRequest');
+const original = 'alice@example.com';
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockSaveMessage.mockImplementation(async (_ctx, message, metadata) => ({
+ ...message,
+ ...(metadata?.privateText && { privacyRevision: metadata.privateText.revision }),
+ }));
+});
+
+it('encrypts denied PII for an existing conversation and sends only the filtered view', async () => {
+ const req = {
+ method: 'POST',
+ path: '/',
+ originalUrl: '/api/agents/chat',
+ config: {
+ filters: {
+ messages: {
+ pii: {
+ action: 'redact',
+ fields: ['text'],
+ starterPatterns: [],
+ customPatterns: [
+ { id: 'email', label: 'Email', regex: 'alice@example\\.com', category: 'email' },
+ ],
+ },
+ },
+ },
+ },
+ user: { id: 'owner', tenantId: 'tenant-a' },
+ body: {
+ text: `Email ${original}`,
+ messageId: uuidv4(),
+ conversationId: uuidv4(),
+ parentMessageId: uuidv4(),
+ clientRequestId: uuidv4(),
+ },
+ };
+ const res = {};
+ const next = jest.fn();
+ createPrivateTextIngress({
+ getFilters: () => req.config.filters,
+ getLegacyPii: () => undefined,
+ getKey: () => 'ab'.repeat(32),
+ })(req, res, next);
+ expect(next).toHaveBeenCalledTimes(1);
+ await denyRequest(req, res, { type: 'message_limit' });
+
+ expect(mockSendEvent).toHaveBeenCalledTimes(1);
+ const event = mockSendEvent.mock.calls[0][1];
+ const [ctx, saved, metadata] = mockSaveMessage.mock.calls[0];
+ expect(mockSaveMessage).toHaveBeenCalledTimes(1);
+ expect(mockSaveMessage.mock.invocationCallOrder[0]).toBeLessThan(
+ mockSendEvent.mock.invocationCallOrder[0],
+ );
+ expect(ctx.userId).toBe('owner');
+ expect(saved.text).toMatch(/^Email \[EMAIL_1_[a-f0-9]{32}\]$/);
+ expect(saved.privacyRevision).toBe(event.message.privacyRevision);
+ expect(metadata.privateText).toMatchObject({ revision: event.message.privacyRevision });
+ expect(metadata.privateText.envelope).toMatch(/^v1:/);
+ expect(event.message.text).toBe(saved.text);
+ expect(JSON.stringify({ event, ctx, saved, metadata })).not.toContain(original);
+ expect(mockSendError).toHaveBeenCalledWith(
+ req,
+ res,
+ expect.objectContaining({ shouldSaveMessage: true }),
+ );
+});
+
+it('does not advertise an original on a denied first turn that is never persisted', async () => {
+ const req = {
+ method: 'POST',
+ path: '/',
+ originalUrl: '/api/agents/chat',
+ config: {
+ filters: {
+ messages: {
+ pii: {
+ action: 'redact',
+ fields: ['text'],
+ starterPatterns: [],
+ customPatterns: [
+ { id: 'email', label: 'Email', regex: 'alice@example\\.com', category: 'email' },
+ ],
+ },
+ },
+ },
+ },
+ user: { id: 'owner', tenantId: 'tenant-a' },
+ body: { text: `Email ${original}`, clientRequestId: uuidv4() },
+ };
+ const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+ const next = jest.fn();
+ createPrivateTextIngress({
+ getFilters: () => req.config.filters,
+ getLegacyPii: () => undefined,
+ getKey: () => 'ab'.repeat(32),
+ })(req, res, next);
+ expect(next).toHaveBeenCalledTimes(1);
+ await denyRequest(req, res, { type: 'ban' });
+
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+ expect(mockSendEvent).toHaveBeenCalledTimes(1);
+ expect(mockSendEvent.mock.calls[0][1].message).toMatchObject({
+ text: expect.stringMatching(/^Email \[EMAIL_1_[a-f0-9]{32}\]$/),
+ isCreatedByUser: true,
+ });
+ expect(mockSendEvent.mock.calls[0][1].message).not.toHaveProperty('privacyRevision');
+ expect(JSON.stringify(mockSendEvent.mock.calls)).not.toContain(original);
+ expect(mockSendError).toHaveBeenCalledWith(
+ req,
+ res,
+ expect.objectContaining({ shouldSaveMessage: false }),
+ );
+});
+
+it('fails closed before event or storage when denial hits untransformed private text', async () => {
+ const req = {
+ path: '/',
+ user: { id: 'owner', tenantId: 'tenant-a' },
+ config: {
+ filters: {
+ messages: {
+ pii: {
+ action: 'redact',
+ fields: ['text'],
+ starterPatterns: [],
+ customPatterns: [
+ { id: 'email', label: 'Email', regex: 'alice@example\\.com', category: 'email' },
+ ],
+ },
+ },
+ },
+ },
+ body: {
+ text: `Email ${original}`,
+ files: [{}],
+ messageId: uuidv4(),
+ conversationId: uuidv4(),
+ parentMessageId: uuidv4(),
+ clientRequestId: uuidv4(),
+ },
+ };
+ const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+ const next = jest.fn();
+ createPrivateTextIngress({
+ getFilters: () => req.config.filters,
+ getLegacyPii: () => undefined,
+ getKey: () => 'ab'.repeat(32),
+ })(req, res, next);
+ expect(next).toHaveBeenCalledTimes(1);
+ await denyRequest(req, res, { type: 'message_limit' });
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(JSON.stringify(res.json.mock.calls)).not.toContain(original);
+ expect(mockSendEvent).not.toHaveBeenCalled();
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+ expect(mockSendError).not.toHaveBeenCalled();
+});
+
+it.each(['redact', 'block'])(
+ 'rejects a queued-turn denial before events or storage with %s policy',
+ async (action) => {
+ const req = {
+ method: 'POST',
+ originalUrl: '/api/agents/chat/queued-turns',
+ user: { id: 'owner', tenantId: 'tenant-a' },
+ config: {
+ filters: {
+ messages: {
+ pii: {
+ action,
+ fields: ['text'],
+ starterPatterns: [],
+ customPatterns: [
+ { id: 'email', label: 'Email', regex: 'alice@example\\.com', category: 'email' },
+ ],
+ },
+ },
+ },
+ },
+ body: { text: `Email ${original}`, conversationId: uuidv4(), parentMessageId: uuidv4() },
+ };
+ const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+ await denyRequest(req, res, { type: 'ban' });
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(JSON.stringify(res.json.mock.calls)).not.toContain(original);
+ expect(mockSendEvent).not.toHaveBeenCalled();
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+ },
+);
+
+it('fails closed on a denied Agent submission when its policy was not loaded', async () => {
+ const req = {
+ method: 'POST',
+ originalUrl: '/api/agents/chat/queued-turns',
+ user: { id: 'owner' },
+ body: { text: original, conversationId: uuidv4(), parentMessageId: uuidv4() },
+ };
+ const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+ await denyRequest(req, res, { type: 'ban' });
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(mockSendEvent).not.toHaveBeenCalled();
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+});
+
+it('retains the existing denial behavior when no PII transformer ran', async () => {
+ const req = {
+ user: { id: 'owner' },
+ body: {
+ text: 'ordinary message',
+ messageId: uuidv4(),
+ conversationId: uuidv4(),
+ parentMessageId: uuidv4(),
+ },
+ };
+ await denyRequest(req, {}, { type: 'message_limit' });
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: 'owner' }),
+ expect.objectContaining({ text: 'ordinary message' }),
+ expect.not.objectContaining({ privateText: expect.anything() }),
+ );
+});
diff --git a/api/server/routes/__tests__/messages-content-edit.spec.js b/api/server/routes/__tests__/messages-content-edit.spec.js
index 9ed509aa45c..ce0ffbb710a 100644
--- a/api/server/routes/__tests__/messages-content-edit.spec.js
+++ b/api/server/routes/__tests__/messages-content-edit.spec.js
@@ -9,6 +9,7 @@ jest.mock('@librechat/agents', () => ({
jest.mock('@librechat/api', () => ({
unescapeLaTeX: jest.fn((value) => value),
countTokens: jest.fn().mockResolvedValue(2),
+ createPrivateTextView: jest.fn(() => (_req, _res, next) => next()),
createContentFilter: jest.fn(() => (_req, _res, next) => next()),
sendFeedbackScore: jest.fn().mockResolvedValue(undefined),
traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`),
diff --git a/api/server/routes/__tests__/messages-delete.spec.js b/api/server/routes/__tests__/messages-delete.spec.js
index a0c27b62b9a..c8174256196 100644
--- a/api/server/routes/__tests__/messages-delete.spec.js
+++ b/api/server/routes/__tests__/messages-delete.spec.js
@@ -9,6 +9,7 @@ jest.mock('@librechat/agents', () => ({
}));
jest.mock('@librechat/api', () => ({
+ createPrivateTextView: jest.fn(() => (_req, _res, next) => next()),
createContentFilter: jest.fn(() => (req, res, next) => next()),
inspectContent: jest.fn(() => null),
extractFeedbackContent: jest.fn(() => []),
diff --git a/api/server/routes/__tests__/messages-feedback.spec.js b/api/server/routes/__tests__/messages-feedback.spec.js
index 46d53a3ce06..b3862ebc283 100644
--- a/api/server/routes/__tests__/messages-feedback.spec.js
+++ b/api/server/routes/__tests__/messages-feedback.spec.js
@@ -8,6 +8,7 @@ jest.mock('@librechat/agents', () => ({
jest.mock('@librechat/api', () => ({
unescapeLaTeX: jest.fn((value) => value),
countTokens: jest.fn().mockResolvedValue(10),
+ createPrivateTextView: jest.fn(() => (_req, _res, next) => next()),
createContentFilter: jest.fn(() => (req, res, next) => next()),
sendFeedbackScore: jest.fn().mockResolvedValue(undefined),
traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`),
diff --git a/api/server/routes/__tests__/messages-get-real-validation.spec.js b/api/server/routes/__tests__/messages-get-real-validation.spec.js
index 46daa50fc00..a7d7e4a4d95 100644
--- a/api/server/routes/__tests__/messages-get-real-validation.spec.js
+++ b/api/server/routes/__tests__/messages-get-real-validation.spec.js
@@ -21,6 +21,7 @@ jest.mock('@librechat/agents', () => ({
}));
jest.mock('@librechat/api', () => ({
+ createPrivateTextView: jest.fn(() => (_req, _res, next) => next()),
createContentFilter: jest.fn(() => (req, res, next) => next()),
inspectContent: jest.fn(() => null),
extractFeedbackContent: jest.fn(() => []),
diff --git a/api/server/routes/__tests__/messages-get.spec.js b/api/server/routes/__tests__/messages-get.spec.js
index b72c0973f48..bac78f10468 100644
--- a/api/server/routes/__tests__/messages-get.spec.js
+++ b/api/server/routes/__tests__/messages-get.spec.js
@@ -54,6 +54,10 @@ jest.mock('@librechat/api', () => {
/** The real helper, without loading the rest of the package this suite mocks around. */
withoutTraceRefs: jest.requireActual('../../../../packages/api/src/langfuse/trace.ts')
.withoutTraceRefs,
+ createPrivateTextView: jest.fn(() => (_req, _res, next) => next()),
+ stripPrivateMessageFields: jest.requireActual(
+ '../../../../packages/api/src/protection/private/view',
+ ).stripPrivateMessageFields,
createContentFilter: jest.fn(() => (req, res, next) => next()),
inspectContent,
extractChatContent,
@@ -640,6 +644,8 @@ describe('message route conversation ownership filters', () => {
messageId: 'hit-1',
conversationId: 'convo-1',
text: 'needle in a haystack',
+ privateText: 'v1:encrypted-original',
+ privacyRevision: 'public-revision',
contextMeta: {
calibrationRatio: 1.2,
encoding: 'claude',
@@ -661,6 +667,8 @@ describe('message route conversation ownership filters', () => {
expect(response.body.messages).toHaveLength(1);
expect(response.body.messages[0]).toMatchObject({ messageId: 'hit-1', title: 'Found' });
expect(response.body.messages[0]).not.toHaveProperty('contextMeta');
+ expect(response.body.messages[0]).not.toHaveProperty('privateText');
+ expect(response.body.messages[0].privacyRevision).toBe('public-revision');
});
it('returns indistinguishable not-found responses for child and missing query reads', async () => {
diff --git a/api/server/routes/__tests__/messages-subagent-thread.spec.js b/api/server/routes/__tests__/messages-subagent-thread.spec.js
index 3c7ee69e759..574a1435e3c 100644
--- a/api/server/routes/__tests__/messages-subagent-thread.spec.js
+++ b/api/server/routes/__tests__/messages-subagent-thread.spec.js
@@ -7,6 +7,7 @@ jest.mock('@librechat/agents', () => ({ sleep: jest.fn() }));
jest.mock('@librechat/api', () => ({
withoutTraceRefs: jest.fn((message) => message),
+ createPrivateTextView: jest.fn(() => (_req, _res, next) => next()),
createContentFilter: jest.fn(() => (_req, _res, next) => next()),
unescapeLaTeX: jest.fn((value) => value),
countTokens: jest.fn().mockResolvedValue(1),
diff --git a/api/server/routes/agents/__tests__/abort.spec.js b/api/server/routes/agents/__tests__/abort.spec.js
index e31bdc95c7d..07c5d6564d2 100644
--- a/api/server/routes/agents/__tests__/abort.spec.js
+++ b/api/server/routes/agents/__tests__/abort.spec.js
@@ -24,6 +24,8 @@ const mockGenerationJobManager = {
};
const mockSaveMessage = jest.fn();
+const mockHasPersistedPrivateText = jest.fn();
+const mockGetPrivateMessageTexts = jest.fn();
const mockRecordScheduleOutcome = jest.fn();
const mockBeginScheduledStop = jest.fn();
@@ -47,6 +49,8 @@ jest.mock('@librechat/api', () => ({
jest.mock('~/models', () => ({
saveMessage: (...args) => mockSaveMessage(...args),
+ hasPersistedPrivateText: (...args) => mockHasPersistedPrivateText(...args),
+ getPrivateMessageTexts: (...args) => mockGetPrivateMessageTexts(...args),
}));
jest.mock('~/server/services/Schedules', () => ({
@@ -99,6 +103,10 @@ describe('Agent Abort Endpoint', () => {
mockGenerationJobManager.getActiveJobIdsForUser.mockReset();
mockSaveMessage.mockReset();
mockSaveMessage.mockImplementation(async (_context, message) => message);
+ mockHasPersistedPrivateText.mockReset();
+ mockHasPersistedPrivateText.mockResolvedValue(true);
+ mockGetPrivateMessageTexts.mockReset();
+ mockGetPrivateMessageTexts.mockResolvedValue([]);
mockRecordScheduleOutcome.mockReset();
mockRecordScheduleOutcome.mockResolvedValue(true);
mockBeginScheduledStop.mockReset();
@@ -361,6 +369,102 @@ describe('Agent Abort Endpoint', () => {
});
describe('Partial Response Saving', () => {
+ it('does not overwrite a persisted protected user sidecar while stopping a run', async () => {
+ const conversationId = 'test-stream-123';
+ const userMessageId = 'protected-user-msg';
+ const privacyRevision = 'protected-revision';
+ const text = 'Email [EMAIL_1_protected]';
+ const abortResult = {
+ success: true,
+ jobData: {
+ userMessage: { messageId: userMessageId, privacyRevision, text },
+ responseMessageId: 'protected-response',
+ conversationId,
+ endpoint: 'agents',
+ },
+ content: [{ type: 'text', text: 'Partial answer' }],
+ text: 'Partial answer',
+ };
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'test-user-123' },
+ });
+ mockGenerationJobManager.abortJob.mockImplementation(async (_streamId, options) => {
+ await options.beforePublish(abortResult);
+ return abortResult;
+ });
+
+ const response = await request(app).post('/api/agents/chat/abort').send({ conversationId });
+
+ expect(response.status).toBe(200);
+ expect(mockHasPersistedPrivateText).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'test-user-123',
+ messageId: userMessageId,
+ conversationId,
+ privacyRevision,
+ text,
+ }),
+ );
+ expect(mockSaveMessage).toHaveBeenCalledTimes(1);
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ messageId: 'protected-response', isCreatedByUser: false }),
+ expect.anything(),
+ );
+ });
+
+ it('recovers an older revisionless job from its exact protected row without unsetting it', async () => {
+ const conversationId = 'test-stream-revisionless';
+ const userMessageId = 'protected-user';
+ const text = `Email [EMAIL_1_${'a'.repeat(32)}]`;
+ const abortResult = {
+ success: true,
+ jobData: {
+ createdEventEmitted: true,
+ userMessage: { messageId: userMessageId, text },
+ responseMessageId: 'protected-response',
+ conversationId,
+ endpoint: 'agents',
+ },
+ finalEvent: { requestMessage: { messageId: userMessageId, text } },
+ content: [],
+ text: '',
+ };
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'test-user-123' },
+ });
+ mockGenerationJobManager.abortJob.mockImplementation(async (_id, options) => {
+ await options.beforePublish(abortResult);
+ return abortResult;
+ });
+ mockGetPrivateMessageTexts.mockResolvedValueOnce([
+ {
+ messageId: userMessageId,
+ text,
+ privacyRevision: 'recovered-revision',
+ privateText: 'v1:protected',
+ },
+ ]);
+
+ const response = await request(app).post('/api/agents/chat/abort').send({ conversationId });
+ expect(response.status).toBe(200);
+ expect(mockGetPrivateMessageTexts).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'test-user-123',
+ conversationId,
+ messageIds: [userMessageId],
+ }),
+ );
+ expect(abortResult.finalEvent.requestMessage.privacyRevision).toBe('recovered-revision');
+ expect(mockHasPersistedPrivateText).not.toHaveBeenCalled();
+ expect(mockSaveMessage).toHaveBeenCalledTimes(1);
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ messageId: 'protected-response' }),
+ expect.anything(),
+ );
+ });
+
it('should save partial response when both userMessage and responseMessageId exist', async () => {
const jobStreamId = 'test-stream-123';
const userMessageId = 'user-msg-123';
diff --git a/api/server/routes/agents/__tests__/idempotencyLimiter.spec.js b/api/server/routes/agents/__tests__/idempotencyLimiter.spec.js
index d911f4d0f25..62b1120837c 100644
--- a/api/server/routes/agents/__tests__/idempotencyLimiter.spec.js
+++ b/api/server/routes/agents/__tests__/idempotencyLimiter.spec.js
@@ -8,6 +8,19 @@ const mockRetryLimiter = jest.fn((_req, _res, next) => next());
const mockRetryProbeLimiter = jest.fn((_req, _res, next) => next());
const mockExemptAgentTrigger = jest.fn(() => false);
const mockExemptSchedule = jest.fn(() => false);
+const mockIngress = jest.fn((req, _res, next) => {
+ if (req.config?.filters?.messages?.pii?.action === 'redact') {
+ req.body.text = '[EMAIL_1]';
+ }
+ next();
+});
+const mockCheckBan = jest.fn((_req, _res, next) => next());
+const mockConfigMiddleware = jest.fn((req, _res, next) => {
+ if (req.headers['x-test-private'] === 'yes') {
+ req.config = { filters: { messages: { pii: { action: 'redact' } } } };
+ }
+ next();
+});
jest.mock('@librechat/data-schemas', () => ({
logger: {
@@ -35,18 +48,28 @@ jest.mock('@librechat/api', () => ({
exemptAgentTriggerFromIpLimiter: (...args) => mockExemptAgentTrigger(...args),
exemptFromUserLimiter: (...args) => mockExemptSchedule(...args),
createMessageFilterPii: jest.fn(() => (_req, _res, next) => next()),
+ createPrivateTextIngress: jest.fn(
+ () =>
+ (...args) =>
+ mockIngress(...args),
+ ),
+ isPreDenialTextSubmission: (req) => req.method === 'POST' && typeof req.body?.text === 'string',
+ isPrivateTextChatSubmission: (req) =>
+ req.method === 'POST' &&
+ req.originalUrl === '/agents/chat' &&
+ typeof req.body?.text === 'string',
}));
jest.mock('~/server/middleware', () => ({
uaParser: (_req, _res, next) => next(),
- checkBan: (_req, _res, next) => next(),
+ checkBan: (...args) => mockCheckBan(...args),
requireJwtAuth: (req, _res, next) => {
req.user = { id: 'user-1' };
next();
},
moderateText: (_req, _res, next) => next(),
messageIpLimiter: (...args) => mockIpLimiter(...args),
- configMiddleware: (_req, _res, next) => next(),
+ configMiddleware: (...args) => mockConfigMiddleware(...args),
messageUserLimiter: (...args) => mockUserLimiter(...args),
}));
@@ -103,6 +126,74 @@ describe('start-generation idempotency before message limiters', () => {
mockExemptSchedule.mockReturnValue(false);
});
+ it('filters before a ban denial, IP limit, and user limit without charging config twice', async () => {
+ mockHasGenerationClaim.mockResolvedValue(false);
+ const payload = { text: 'alice@example.com', clientRequestId: 'request-privacy' };
+ mockCheckBan.mockImplementationOnce((req, res) => {
+ expect(req.body.text).toBe('[EMAIL_1]');
+ res.status(403).json({ banned: true });
+ });
+ const banned = await request(app)
+ .post('/agents/chat')
+ .set('X-Test-Private', 'yes')
+ .send(payload);
+ expect(banned.status).toBe(403);
+ expect(mockIpLimiter).not.toHaveBeenCalled();
+
+ mockIpLimiter.mockImplementationOnce((req, res) => {
+ expect(req.body.text).toBe('[EMAIL_1]');
+ res.status(429).json({ limited: 'ip' });
+ });
+ const ipLimited = await request(app)
+ .post('/agents/chat')
+ .set('X-Test-Private', 'yes')
+ .send(payload);
+ expect(ipLimited.status).toBe(429);
+ expect(mockUserLimiter).not.toHaveBeenCalled();
+
+ mockIpLimiter.mockImplementationOnce((_req, _res, next) => next());
+ mockUserLimiter.mockImplementationOnce((req, res) => {
+ expect(req.body.text).toBe('[EMAIL_1]');
+ res.status(429).json({ limited: 'user' });
+ });
+ const userLimited = await request(app)
+ .post('/agents/chat')
+ .set('X-Test-Private', 'yes')
+ .send(payload);
+ expect(userLimited.status).toBe(429);
+ expect(mockIngress).toHaveBeenCalledTimes(3);
+ expect(mockConfigMiddleware).toHaveBeenCalledTimes(3);
+ });
+
+ it.each(['/agents/chat/queued-turns', '/agents/chat/queued-turns/v2', '/agents/chat/steer'])(
+ 'loads policy once ahead of a banned text submission to %s without transforming it',
+ async (path) => {
+ mockCheckBan.mockImplementationOnce((req, res) => {
+ expect(req.config?.filters?.messages?.pii?.action).toBe('redact');
+ expect(req.body.text).toBe('alice@example.com');
+ res.status(403).json({ banned: true });
+ });
+ const response = await request(app)
+ .post(path)
+ .set('X-Test-Private', 'yes')
+ .send({ text: 'alice@example.com' });
+ expect(response.status).toBe(403);
+ expect(mockConfigMiddleware).toHaveBeenCalledTimes(1);
+ expect(mockIngress).not.toHaveBeenCalled();
+ },
+ );
+
+ it('does not reload pre-denial config on an admitted queued submission', async () => {
+ mockIpLimiter.mockImplementationOnce((_req, _res, next) => next());
+ mockUserLimiter.mockImplementationOnce((_req, _res, next) => next());
+ const response = await request(app)
+ .post('/agents/chat/queued-turns')
+ .set('X-Test-Private', 'yes')
+ .send({ text: 'clean queued turn' });
+ expect(response.status).toBe(202);
+ expect(mockConfigMiddleware).toHaveBeenCalledTimes(1);
+ });
+
it('keeps a confirmed retry behind the shared IP limiter', async () => {
mockHasGenerationClaim.mockResolvedValue(true);
mockIpLimiter.mockImplementationOnce((_req, _res, next) => next());
diff --git a/api/server/routes/agents/__tests__/streamTenant.spec.js b/api/server/routes/agents/__tests__/streamTenant.spec.js
index e23338c937f..94c7710ced9 100644
--- a/api/server/routes/agents/__tests__/streamTenant.spec.js
+++ b/api/server/routes/agents/__tests__/streamTenant.spec.js
@@ -89,7 +89,10 @@ describe('SSE stream tenant isolation', () => {
checkpointIds: ['checkpoint-a'],
});
mockDeleteAgentCheckpoint.mockResolvedValue(undefined);
- mockSaveMessage.mockResolvedValue({ persisted: true });
+ mockSaveMessage.mockImplementation(async (_context, message) => ({
+ ...message,
+ persisted: true,
+ }));
mockGenerationJobManager.getActiveJobIdsForUser.mockResolvedValue([]);
mockGenerationJobManager.steering.claim.mockResolvedValue([]);
mockGenerationJobManager.steering.claimDetailed.mockResolvedValue({
@@ -982,7 +985,7 @@ describe('SSE stream tenant isolation', () => {
1,
expect.any(Object),
expect.objectContaining({ messageId: 'user-1', isCreatedByUser: true }),
- expect.any(Object),
+ expect.objectContaining({ insertOnly: true }),
);
expect(mockSaveMessage).toHaveBeenNthCalledWith(
2,
diff --git a/api/server/routes/agents/chat.js b/api/server/routes/agents/chat.js
index 16ecf54fd04..b9c9770089a 100644
--- a/api/server/routes/agents/chat.js
+++ b/api/server/routes/agents/chat.js
@@ -2,6 +2,7 @@ const express = require('express');
const { logger } = require('@librechat/data-schemas');
const {
createMessageFilterPii,
+ getPreinspectedPrivateText,
reportLocatorTraversalFailure,
generateCheckAccess,
skipAgentCheck,
@@ -76,6 +77,7 @@ router.use(
onTraversalFailure: reportLocatorTraversalFailure,
getConfig: (req) => req.config?.messageFilter?.pii,
getFilters: (req) => req.config?.filters,
+ getPreinspectedText: getPreinspectedPrivateText,
getFiles,
}),
);
diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js
index 52a4c34eeb1..bec4e4a6c8f 100644
--- a/api/server/routes/agents/index.js
+++ b/api/server/routes/agents/index.js
@@ -15,6 +15,10 @@ const {
attachAskUserQuestionAnswers,
attachAskUserQuestionArgs,
createMessageFilterPii,
+ createPrivateTextIngress,
+ isPrivateTextChatSubmission,
+ isPreDenialTextSubmission,
+ saveAbortedUserMessage,
isAgentTriggerRequest,
exemptAgentTriggerFromIpLimiter,
captureScheduleFireContext,
@@ -49,7 +53,12 @@ const {
getServerGenerationProtocol,
negotiateExistingGenerationProtocol,
} = require('~/server/controllers/agents/protocol');
-const { getFiles, saveMessage } = require('~/models');
+const {
+ getFiles,
+ saveMessage,
+ hasPersistedPrivateText,
+ getPrivateMessageTexts,
+} = require('~/models');
const {
recordScheduleOutcome,
beginScheduledStop,
@@ -146,6 +155,19 @@ router.use((req, _res, next) => {
captureScheduleFireContext(req);
next();
});
+// Denials may persist submitted text on chat control routes too. Load policy before
+// any such denial; transformation remains limited to fresh interactive turns.
+const privateTextIngress = createPrivateTextIngress({
+ getFilters: (req) => req.config?.filters,
+ getLegacyPii: (req) => req.config?.messageFilter?.pii,
+ getKey: () => process.env.CREDS_KEY ?? '',
+});
+const chatConfigMiddleware = unless((req) => req.config != null, configMiddleware);
+router.use(
+ '/chat',
+ unless((req) => !isPreDenialTextSubmission(req), configMiddleware),
+ unless((req) => !isPrivateTextChatSubmission(req), privateTextIngress),
+);
router.use(checkBan);
router.use(uaParser);
@@ -567,7 +589,7 @@ router.get('/chat/status/:conversationId', async (req, res) => {
* @access Private
* @description Mounted before chatRouter to bypass buildEndpointOption middleware
*/
-router.post('/chat/abort', configMiddleware, async (req, res, next) => {
+router.post('/chat/abort', chatConfigMiddleware, async (req, res, next) => {
logger.debug(`[AgentStream] ========== ABORT ENDPOINT HIT ==========`);
logger.debug(`[AgentStream] Method: ${req.method}, Path: ${req.path}`);
@@ -829,9 +851,14 @@ router.post('/chat/abort', configMiddleware, async (req, res, next) => {
* write and checkpoint cleanup so every independently useful
* operation gets a chance to succeed. */
try {
- const persistedRequest = await saveMessage(messageContext, requestMessage, {
- context: 'api/server/routes/agents/index.js - abort user prerequisite',
- });
+ const persistedRequest = await saveAbortedUserMessage(
+ { saveMessage, hasPersistedPrivateText, getPrivateMessageTexts },
+ messageContext,
+ requestMessage,
+ { context: 'api/server/routes/agents/index.js - abort user prerequisite' },
+ req.user?.tenantId,
+ pendingAbortResult.finalEvent,
+ );
if (!persistedRequest) {
throw new Error('Abort user prerequisite was not persisted');
}
@@ -1053,7 +1080,7 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
}
router.post(
'/chat/steer',
- configMiddleware,
+ chatConfigMiddleware,
...steerLimiters,
createMessageFilterPii({
onTraversalFailure: reportLocatorTraversalFailure,
@@ -1074,7 +1101,7 @@ router.post(
*/
router.post(
'/chat/steer/deliver',
- configMiddleware,
+ chatConfigMiddleware,
...steerLimiters,
createMessageFilterPii({
onTraversalFailure: reportLocatorTraversalFailure,
@@ -1094,7 +1121,7 @@ router.post(
*/
router.post(
'/chat/steer/cancel',
- configMiddleware,
+ chatConfigMiddleware,
...steerLimiters,
SteerController.SteerCancelController,
);
@@ -1107,14 +1134,14 @@ router.post(
*/
router.post(
'/chat/steer/arm',
- configMiddleware,
+ chatConfigMiddleware,
...steerLimiters,
SteerController.SteerArmController,
);
router.post(
'/chat/queued-turns',
- configMiddleware,
+ chatConfigMiddleware,
...steerLimiters,
createMessageFilterPii({
onTraversalFailure: reportLocatorTraversalFailure,
@@ -1127,7 +1154,7 @@ router.post(
);
router.post(
'/chat/queued-turns/v2',
- configMiddleware,
+ chatConfigMiddleware,
...steerLimiters,
createMessageFilterPii({
onTraversalFailure: reportLocatorTraversalFailure,
@@ -1140,10 +1167,10 @@ router.post(
);
/** Synchronizing durable queue state is read-only and polled while work is
* pending. It must not consume the model-submission admission budget. */
-router.get('/chat/queued-turns', configMiddleware, AgentQueuedTurnListController);
+router.get('/chat/queued-turns', chatConfigMiddleware, AgentQueuedTurnListController);
router.delete(
'/chat/queued-turns/:queuedTurnId',
- configMiddleware,
+ chatConfigMiddleware,
...steerLimiters,
AgentQueuedTurnCancelController,
);
@@ -1153,7 +1180,7 @@ router.use('/', v1);
const chatRouter = express.Router();
const useMessageIpLimiter = isEnabled(LIMIT_MESSAGE_IP);
const useMessageUserLimiter = isEnabled(LIMIT_MESSAGE_USER);
-chatRouter.use(configMiddleware);
+chatRouter.use(chatConfigMiddleware);
if (useMessageIpLimiter || useMessageUserLimiter) {
chatRouter.use(
unless(
diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js
index 6ac391c18d4..6797595251b 100644
--- a/api/server/routes/messages.js
+++ b/api/server/routes/messages.js
@@ -27,6 +27,8 @@ const {
mergeUserSubmittedMessageFieldPaths,
isContentFilterError,
withoutTraceRefs,
+ createPrivateTextView,
+ stripPrivateMessageFields,
} = require('@librechat/api');
const subagentThreadTaskStore = require('~/server/services/Endpoints/agents/subagentThreadStore');
const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
@@ -63,6 +65,13 @@ const storedMessageMutationMiddleware = [
];
router.use(requireJwtAuth);
+router.post(
+ '/:conversationId/owner-text',
+ createPrivateTextView({
+ read: db.getPrivateMessageTexts,
+ getKey: () => process.env.CREDS_KEY ?? '',
+ }),
+);
async function rejectSubagentThreadWrite(req, res, conversationId) {
const blocked = await isSubagentThreadWriteBlocked(
@@ -191,9 +200,8 @@ router.get('/', async (req, res) => {
for (const message of cleanedMessages) {
const convo = result.convoMap[message.conversationId];
const dbMessage = dbMessageMap[message.messageId];
- /** Search hydrates every schema field; server-private state never leaves. */
- const publicHit = { ...message };
- delete publicHit.contextMeta;
+ /** Search may hydrate server-private fields; only a public projection leaves. */
+ const publicHit = stripPrivateMessageFields(message);
activeMessages.push({
...publicHit,
@@ -235,9 +243,7 @@ router.get('/', async (req, res) => {
* @returns {TMessage}
*/
function toClientMessage(message) {
- const clientMessage = { ...message };
- delete clientMessage.contextMeta;
- return clientMessage;
+ return stripPrivateMessageFields(message);
}
router.post('/branch', configMiddleware, async (req, res) => {
diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js
index b777eed80da..6db2f8113e9 100644
--- a/api/server/utils/import/fork.js
+++ b/api/server/utils/import/fork.js
@@ -29,6 +29,8 @@ function cloneMessagesWithTimestamps(
parentMessageId,
createdAt,
};
+ delete clonedMessage.privateText;
+ delete clonedMessage.privacyRevision;
if (detachSubagentRuntime) {
delete clonedMessage.subagentTask;
delete clonedMessage.subagentTranscript;
diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js
index d53bb8775c1..97cc3d54c29 100644
--- a/api/server/utils/import/fork.spec.js
+++ b/api/server/utils/import/fork.spec.js
@@ -1453,6 +1453,27 @@ describe('splitAtTargetLevel', () => {
});
describe('cloneMessagesWithTimestamps', () => {
+ test('does not carry private owner metadata into a new message identity', () => {
+ const importBatchBuilder = createImportBatchBuilder('owner');
+ importBatchBuilder.startConversation();
+ cloneMessagesWithTimestamps(
+ [
+ {
+ messageId: 'source',
+ parentMessageId: Constants.NO_PARENT,
+ text: '[EMAIL_1]',
+ isCreatedByUser: true,
+ privateText: 'v1:ciphertext',
+ privacyRevision: 'source-revision',
+ },
+ ],
+ importBatchBuilder,
+ );
+ const cloned = importBatchBuilder.messages[0];
+ expect(cloned.text).toBe('[EMAIL_1]');
+ expect(cloned).not.toHaveProperty('privateText');
+ expect(cloned).not.toHaveProperty('privacyRevision');
+ });
test('should preserve user-submitted provenance without marking untouched model output', () => {
const messagesToClone = [
{
diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js
index d496749e5cd..ee211c67888 100644
--- a/api/server/utils/import/importers.js
+++ b/api/server/utils/import/importers.js
@@ -68,6 +68,8 @@ function sanitizeImportedMessage(message) {
/** Server-private run state and trace sampling records never come from an import. */
const importable = withoutTraceRefs({ ...message });
delete importable.contextMeta;
+ delete importable.privateText;
+ delete importable.privacyRevision;
return {
...importable,
isUserSubmitted: true,
diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js
index b9d945af94c..fed74ac4aba 100644
--- a/api/server/utils/import/importers.spec.js
+++ b/api/server/utils/import/importers.spec.js
@@ -38,6 +38,28 @@ afterEach(() => {
jest.clearAllMocks();
});
+it('strips server-private owner metadata from untrusted LibreChat imports', async () => {
+ const jsonData = {
+ conversationId: 'imported',
+ title: 'Imported',
+ messages: [
+ {
+ messageId: 'source',
+ parentMessageId: Constants.NO_PARENT,
+ text: '[EMAIL_1]',
+ isCreatedByUser: true,
+ privateText: 'v1:forged',
+ privacyRevision: 'forged',
+ },
+ ],
+ };
+ const importBatchBuilder = new ImportBatchBuilder('owner');
+ await getImporter(jsonData)(jsonData, 'owner', () => importBatchBuilder);
+ expect(importBatchBuilder.messages[0].text).toBe('[EMAIL_1]');
+ expect(importBatchBuilder.messages[0]).not.toHaveProperty('privateText');
+ expect(importBatchBuilder.messages[0]).not.toHaveProperty('privacyRevision');
+});
+
describe('importChatGptConvo', () => {
it('should import conversation correctly', async () => {
const expectedNumberOfMessages = 19;
diff --git a/api/test/server/middleware/checkBan.test.js b/api/test/server/middleware/checkBan.test.js
index 39775c389a4..aeb2f4e3162 100644
--- a/api/test/server/middleware/checkBan.test.js
+++ b/api/test/server/middleware/checkBan.test.js
@@ -219,6 +219,19 @@ describe('checkBan middleware', () => {
},
);
+ it('treats a banned queued-turn POST as an early text-saving denial', async () => {
+ mockBanCacheGet.mockResolvedValueOnce({ expiresAt: Date.now() + 60000 });
+ const req = createReq({
+ method: 'POST',
+ baseUrl: '/api/agents',
+ originalUrl: '/api/agents/chat/queued-turns',
+ body: { text: 'submitted text' },
+ });
+ const res = createRes();
+ await checkBan(req, res, jest.fn());
+ expect(denyRequest).toHaveBeenCalledWith(req, res, { type: ViolationTypes.BAN });
+ });
+
it('preserves SSE denial for a banned browser interactive chat request', async () => {
mockBanCacheGet.mockResolvedValueOnce({ expiresAt: Date.now() + 60000 });
const req = createReq({
diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx
index ef6949da36a..0ba13956295 100644
--- a/client/src/components/Chat/ChatView.tsx
+++ b/client/src/components/Chat/ChatView.tsx
@@ -22,6 +22,7 @@ import { QueuedTurnPortalProvider } from './Steering/QueuedTurnPortal';
import ApprovalProvider from './Messages/Content/ApprovalContext';
import ConversationStarters from './Input/ConversationStarters';
import { pendingApprovalActionFamily } from './approval/state';
+import { OwnerTextProvider } from './Messages/PrivateText';
import { useGetMessagesByConvoId } from '~/data-provider';
import Footer, { useConfiguredFooter } from './Footer';
import { AskAnswerHostProvider } from './ask/state';
@@ -187,7 +188,13 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
: 'h-full overflow-y-auto',
)}
>
- {content}
+
+ {content}
+
{/* Named + opaque so a view transition (the ask_user_question
popover ⇄ chat-card morph) paints the whole composer band
over the travelling card instead of letting it show
diff --git a/client/src/components/Chat/Messages/Content/EditMessage.tsx b/client/src/components/Chat/Messages/Content/EditMessage.tsx
index 0920950cd9b..e05f08efdb2 100644
--- a/client/src/components/Chat/Messages/Content/EditMessage.tsx
+++ b/client/src/components/Chat/Messages/Content/EditMessage.tsx
@@ -162,6 +162,7 @@ const EditMessage = ({
);
if (!isInMessages) {
message.text = data.text;
+ message.privacyRevision = undefined;
} else {
setMessages(
messages.map((msg) =>
@@ -169,6 +170,7 @@ const EditMessage = ({
? {
...msg,
text: data.text,
+ privacyRevision: undefined,
}
: msg,
),
diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx
index f7f75459779..a9e222b09dc 100644
--- a/client/src/components/Chat/Messages/Content/MessageContent.tsx
+++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx
@@ -97,7 +97,7 @@ export const ErrorMessage = ({
);
};
-const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
+export const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const collapseLongUserMessages = useRecoilValue(store.collapseLongUserMessages);
diff --git a/client/src/components/Chat/Messages/Content/__tests__/EditMessage.spec.tsx b/client/src/components/Chat/Messages/Content/__tests__/EditMessage.spec.tsx
index 6cb0126658c..7a893906569 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/EditMessage.spec.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/EditMessage.spec.tsx
@@ -129,6 +129,28 @@ describe('EditMessage', () => {
expect(enterEdit).toHaveBeenCalledWith(true);
});
+ it('clears the private revision when a protected message is saved', async () => {
+ const protectedMessage = { ...message, privacyRevision: 'previous-revision' };
+ mockGetMessages.mockReturnValue([protectedMessage]);
+ const user = userEvent.setup();
+ renderEditor({ editedMessage: protectedMessage });
+
+ await user.clear(screen.getByTestId('message-text-editor'));
+ await user.type(screen.getByTestId('message-text-editor'), 'Clean edited message');
+ await user.click(screen.getByRole('button', { name: 'com_ui_save' }));
+
+ await waitFor(() =>
+ expect(mockSetMessages).toHaveBeenCalledWith([
+ expect.objectContaining({
+ messageId: message.messageId,
+ text: 'Clean edited message',
+ privacyRevision: undefined,
+ }),
+ ]),
+ );
+ expect(protectedMessage.privacyRevision).toBe('previous-revision');
+ });
+
it('writes the save onto the thread as it stands when the request resolves', async () => {
const user = userEvent.setup();
const streamedAnswer = {
diff --git a/client/src/components/Chat/Messages/PrivateText.spec.tsx b/client/src/components/Chat/Messages/PrivateText.spec.tsx
new file mode 100644
index 00000000000..76754777c99
--- /dev/null
+++ b/client/src/components/Chat/Messages/PrivateText.spec.tsx
@@ -0,0 +1,294 @@
+import { dataService } from 'librechat-data-provider';
+import { render, screen, waitFor, act } from '@testing-library/react';
+import type { TMessage } from 'librechat-data-provider';
+import { OwnerTextProvider, PrivateText } from './PrivateText';
+
+let mockOwnerId = 'owner';
+let mockTenantId = 'tenant-a';
+jest.mock('~/hooks/AuthContext', () => ({
+ useAuthContext: () => ({ user: { id: mockOwnerId, tenantId: mockTenantId } }),
+}));
+jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key }));
+jest.mock('librechat-data-provider', () => ({ dataService: { getOwnerMessageTexts: jest.fn() } }));
+jest.mock('./Content/MessageContent', () => ({
+ DisplayMessage: ({ text, message }: { text: string; message: TMessage }) => (
+
+ {text}
+
+ ),
+}));
+
+const canonical = Object.freeze({
+ messageId: 'message',
+ conversationId: 'conversation',
+ isCreatedByUser: true,
+ text: '[EMAIL_1_turn]',
+ privacyRevision: 'turn',
+}) as TMessage;
+const load = dataService.getOwnerMessageTexts as jest.Mock;
+const original = {
+ canonicalText: canonical.text,
+ messageId: 'message',
+ revision: 'turn',
+ text: 'alice@example.com',
+};
+function View({
+ conversationId = 'conversation',
+ messages = [canonical],
+ displayIndex = 0,
+ isSubmitting = false,
+}: {
+ conversationId?: string;
+ messages?: TMessage[];
+ displayIndex?: number;
+ isSubmitting?: boolean;
+}) {
+ return (
+
+
+ {JSON.stringify(messages)}
+
+ );
+}
+beforeEach(() => {
+ mockOwnerId = 'owner';
+ mockTenantId = 'tenant-a';
+ load.mockReset();
+});
+
+it('renders originals without mutating canonical model/export input, and reloads from the private API', async () => {
+ load.mockResolvedValue({ messages: [original] });
+ const first = render();
+ expect(await screen.findByText('alice@example.com')).toBeInTheDocument();
+ expect(screen.getByTestId('canonical')).not.toHaveTextContent('alice@example.com');
+ expect(screen.getByTestId('standard-user-renderer')).toHaveAttribute(
+ 'data-canonical',
+ canonical.text,
+ );
+ expect(canonical.text).toBe('[EMAIL_1_turn]');
+ expect(screen.getByRole('status')).toHaveTextContent('com_ui_private_text_hidden');
+ first.unmount();
+ render();
+ expect(await screen.findByText('alice@example.com')).toBeInTheDocument();
+ expect(load).toHaveBeenCalledTimes(2);
+});
+
+it('loads the acknowledged first-turn owner text before navigating away from /new', async () => {
+ const firstTurn = {
+ ...canonical,
+ conversationId: '11111111-1111-4111-8111-111111111111',
+ };
+ load.mockResolvedValue({ messages: [{ ...original, canonicalText: firstTurn.text }] });
+ const view = render();
+ expect(await screen.findByText(original.text)).toBeInTheDocument();
+ expect(load).toHaveBeenCalledWith(firstTurn.conversationId, ['message']);
+ expect(load).not.toHaveBeenCalledWith('new', expect.anything());
+
+ view.rerender(
+ ,
+ );
+ expect(screen.getByText(original.text)).toBeInTheDocument();
+ expect(load).toHaveBeenCalledTimes(1);
+});
+
+it('never mixes protected rows from stale conversations into a first-turn owner read', async () => {
+ const old = {
+ ...canonical,
+ messageId: 'old-message',
+ conversationId: '11111111-1111-4111-8111-111111111111',
+ };
+ const fresh = {
+ ...canonical,
+ messageId: 'fresh-message',
+ conversationId: '22222222-2222-4222-8222-222222222222',
+ };
+ load.mockResolvedValue({ messages: [{ ...original, messageId: 'fresh-message' }] });
+ render();
+ expect(await screen.findByText(original.text)).toBeInTheDocument();
+ expect(load).toHaveBeenCalledTimes(1);
+ expect(load).toHaveBeenCalledWith(fresh.conversationId, ['fresh-message']);
+});
+
+it('does not fetch originals for an ordinary transcript', () => {
+ const plain = { ...canonical, privacyRevision: undefined };
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId('ordinary-transcript')).toBeInTheDocument();
+ expect(load).not.toHaveBeenCalled();
+});
+
+it('renders only filtered text without an owner provider, as on external viewers', () => {
+ render();
+ expect(screen.getByText(canonical.text)).toBeInTheDocument();
+ expect(load).not.toHaveBeenCalled();
+});
+
+it('shows loading then safe unavailable text when decryption or authorization fails', async () => {
+ let finish!: (value: { messages: [] }) => void;
+ load.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ render();
+ expect(screen.getByRole('status')).toHaveTextContent('com_ui_private_text_loading');
+ await act(async () => {
+ finish({ messages: [] });
+ });
+ expect(screen.getByText(canonical.text)).toBeInTheDocument();
+ expect(screen.getByRole('status')).toHaveTextContent('com_ui_private_text_unavailable');
+});
+
+it('retries a provisional empty owner read once on turn completion, without repeatedly polling', async () => {
+ load.mockResolvedValueOnce({ messages: [] });
+ load.mockResolvedValueOnce({ messages: [original] });
+ const view = render();
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent('com_ui_private_text_unavailable'),
+ );
+ expect(load).toHaveBeenCalledTimes(1);
+ view.rerender();
+ expect(load).toHaveBeenCalledTimes(1);
+ view.rerender();
+ expect(await screen.findByText(original.text)).toBeInTheDocument();
+ expect(load).toHaveBeenCalledTimes(2);
+ view.rerender();
+ expect(load).toHaveBeenCalledTimes(2);
+});
+
+it('does not automatically retry an old missing owner row on unrelated submission transitions', async () => {
+ load.mockResolvedValue({ messages: [] });
+ const view = render();
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent('com_ui_private_text_unavailable'),
+ );
+ view.rerender();
+ view.rerender();
+ expect(load).toHaveBeenCalledTimes(1);
+});
+
+it('offers a safe retry after a transient owner-text request failure', async () => {
+ load.mockRejectedValueOnce(new Error('temporary outage'));
+ load.mockResolvedValueOnce({ messages: [original] });
+ render();
+
+ const retry = await screen.findByRole('button', { name: 'com_ui_private_text_retry' });
+ expect(screen.getByText(canonical.text)).toBeInTheDocument();
+ expect(screen.getByTestId('canonical')).not.toHaveTextContent(original.text);
+ await act(async () => {
+ retry.click();
+ });
+ expect(await screen.findByText(original.text)).toBeInTheDocument();
+ expect(load).toHaveBeenCalledTimes(2);
+ expect(screen.queryByRole('button', { name: 'com_ui_private_text_retry' })).toBeNull();
+});
+
+it('does not cache a failed decryption as if it contained a usable original', async () => {
+ load.mockResolvedValueOnce({ messages: [{ ...original, text: undefined }] });
+ load.mockResolvedValueOnce({ messages: [original] });
+ render();
+ const retry = await screen.findByRole('button', { name: 'com_ui_private_text_retry' });
+ expect(screen.getByText(canonical.text)).toBeInTheDocument();
+ await act(async () => retry.click());
+ expect(await screen.findByText(original.text)).toBeInTheDocument();
+ expect(load).toHaveBeenCalledTimes(2);
+});
+
+it('rejects stale revisions instead of restoring a previous original', async () => {
+ load.mockResolvedValue({ messages: [{ ...original, revision: 'old-revision' }] });
+ render();
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent('com_ui_private_text_unavailable'),
+ );
+ expect(screen.queryByText(original.text)).not.toBeInTheDocument();
+});
+
+it('clears the visible original immediately on account switching, ignoring late responses', async () => {
+ let finish!: (value: { messages: (typeof original)[] }) => void;
+ load.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ load.mockResolvedValue({ messages: [] });
+ const view = render();
+ mockOwnerId = 'another-owner';
+ view.rerender();
+ await act(async () => {
+ finish({ messages: [original] });
+ });
+ expect(screen.queryByText(original.text)).not.toBeInTheDocument();
+ expect(screen.getByText(canonical.text)).toBeInTheDocument();
+});
+
+it('batches selected private rows and never loads ordinary messages', async () => {
+ load.mockResolvedValue({ messages: [] });
+ const messages: TMessage[] = Array.from({ length: 51 }, (_, index) => ({
+ ...canonical,
+ messageId: `message-${index}`,
+ }));
+ messages.push({ ...canonical, messageId: 'plain', privacyRevision: undefined });
+ render();
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(2));
+ expect(load.mock.calls.map(([, ids]) => ids.length)).toEqual([50, 1]);
+ expect(load.mock.calls.flatMap(([, ids]) => ids)).not.toContain('plain');
+});
+
+it('loads batches concurrently, publishes completed batches, and only fetches new revisions', async () => {
+ let finishFirst!: (value: { messages: (typeof original)[] }) => void;
+ load.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishFirst = resolve;
+ }),
+ );
+ load.mockResolvedValue({ messages: [{ ...original, messageId: 'message-9' }] });
+ const messages: TMessage[] = Array.from({ length: 51 }, (_, index) => ({
+ ...canonical,
+ messageId: `message-${index}`,
+ }));
+ const view = render();
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(2));
+ expect(await screen.findByText(original.text)).toBeInTheDocument();
+ await act(async () => {
+ finishFirst({
+ messages: load.mock.calls[0][1].map((id: string) => ({ ...original, messageId: id })),
+ });
+ });
+ view.rerender(
+ ,
+ );
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(3));
+ expect(load.mock.calls[2][1]).toEqual(['message-51']);
+ expect(screen.getByText(original.text)).toBeInTheDocument();
+});
+
+it('invalidates an already rendered original when the canonical message changes', async () => {
+ load.mockResolvedValue({ messages: [original] });
+ const view = render();
+ expect(await screen.findByText(original.text)).toBeInTheDocument();
+ view.rerender();
+ expect(screen.queryByText(original.text)).not.toBeInTheDocument();
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent('com_ui_private_text_unavailable'),
+ );
+});
+
+it('clears originals when tenant identity changes even if the user ID is unchanged', async () => {
+ load.mockResolvedValueOnce({ messages: [original] });
+ const view = render();
+ expect(await screen.findByText(original.text)).toBeInTheDocument();
+ load.mockResolvedValue({ messages: [] });
+ mockTenantId = 'tenant-b';
+ view.rerender();
+ expect(screen.queryByText(original.text)).not.toBeInTheDocument();
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(2));
+});
diff --git a/client/src/components/Chat/Messages/PrivateText.tsx b/client/src/components/Chat/Messages/PrivateText.tsx
new file mode 100644
index 00000000000..112f9f87d3e
--- /dev/null
+++ b/client/src/components/Chat/Messages/PrivateText.tsx
@@ -0,0 +1,237 @@
+import {
+ createContext,
+ lazy,
+ Suspense,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { dataService } from 'librechat-data-provider';
+import type { TMessage } from 'librechat-data-provider';
+import type { ReactNode } from 'react';
+import { useAuthContext } from '~/hooks/AuthContext';
+import { useLocalize } from '~/hooks';
+const DisplayMessage = lazy(async () => ({
+ default: (await import('./Content/MessageContent')).DisplayMessage,
+}));
+
+interface Original {
+ canonicalText: string;
+ revision: string;
+ text?: string;
+}
+interface MissingOriginal {
+ canonicalText: string;
+ revision: string;
+ retryOnCompletion: boolean;
+}
+interface OwnerTextState {
+ scope: string;
+ messages: ReadonlyMap;
+ loading: boolean;
+ retry?: () => void;
+}
+const empty: OwnerTextState = { scope: '', messages: new Map(), loading: false };
+const OwnerTextContext = createContext(empty);
+
+interface OwnerTextProviderProps {
+ messages: readonly TMessage[] | null;
+ conversationId?: string;
+ isSubmitting: boolean;
+ children: ReactNode;
+}
+
+export function OwnerTextProvider(props: OwnerTextProviderProps) {
+ const protectedMessage = props.messages?.findLast(
+ (message) => message.isCreatedByUser && message.privacyRevision && message.conversationId,
+ );
+ if (protectedMessage == null) {
+ return <>{props.children}>;
+ }
+ const conversationId =
+ props.conversationId === 'new' &&
+ typeof protectedMessage.conversationId === 'string' &&
+ protectedMessage.conversationId !== 'new'
+ ? protectedMessage.conversationId
+ : props.conversationId;
+ return ;
+}
+
+function ActiveOwnerTextProvider({
+ messages,
+ conversationId,
+ isSubmitting,
+ children,
+}: OwnerTextProviderProps) {
+ const { user } = useAuthContext();
+ const selection = useMemo(
+ () =>
+ JSON.stringify(
+ (messages ?? [])
+ .filter(
+ (message) =>
+ message.isCreatedByUser &&
+ message.privacyRevision &&
+ message.conversationId === conversationId,
+ )
+ .map((message) => [message.messageId, message.privacyRevision, message.text])
+ .sort(),
+ ),
+ [messages, conversationId],
+ );
+ const scope = JSON.stringify([user?.id, user?.tenantId, conversationId, selection]);
+ const [state, setState] = useState(empty);
+ const [retryAttempt, setRetryAttempt] = useState(0);
+ const cached = useRef<{
+ scope: string;
+ messages: Map;
+ missing: Map;
+ retryAttempt: number;
+ }>({ scope: '', messages: new Map(), missing: new Map(), retryAttempt: 0 });
+ useEffect(() => {
+ let cancelled = false;
+ const selected = JSON.parse(selection) as Array<[string, string, string]>;
+ if (!user?.id || !conversationId || selected.length === 0) {
+ cached.current = { scope: '', messages: new Map(), missing: new Map(), retryAttempt };
+ setState(empty);
+ return;
+ }
+ const ownerScope = JSON.stringify([user.id, user.tenantId, conversationId]);
+ if (cached.current.scope !== ownerScope) {
+ cached.current = { scope: ownerScope, messages: new Map(), missing: new Map(), retryAttempt };
+ }
+ const forceRetry = cached.current.retryAttempt !== retryAttempt;
+ cached.current.retryAttempt = retryAttempt;
+ const originals = new Map();
+ const missing = new Map();
+ const pending: Array<[string, string, string]> = [];
+ for (const [id, revision, text] of selected) {
+ const prior = cached.current.messages.get(id);
+ if (prior?.revision === revision && prior.canonicalText === text && prior.text != null) {
+ originals.set(id, prior);
+ continue;
+ }
+ const missed = cached.current.missing.get(id);
+ if (missed?.revision === revision && missed.canonicalText === text) {
+ if (!forceRetry && (isSubmitting || !missed.retryOnCompletion)) {
+ missing.set(id, missed);
+ continue;
+ }
+ }
+ pending.push([id, revision, text]);
+ }
+ // Do not retain originals or failed reads from removed or edited messages.
+ cached.current.messages = originals;
+ cached.current.missing = missing;
+ const retry = () => setRetryAttempt((attempt) => attempt + 1);
+ setState({ scope, messages: new Map(originals), loading: pending.length > 0, retry });
+ if (pending.length === 0) {
+ return;
+ }
+ let next = 0;
+ const load = async () => {
+ const workers = Array.from(
+ { length: Math.min(3, Math.ceil(pending.length / 50)) },
+ async () => {
+ while (next < pending.length) {
+ const start = next;
+ next += 50;
+ const batch = pending.slice(start, start + 50);
+ const expected = new Map(batch.map(([id, revision, text]) => [id, { revision, text }]));
+ try {
+ const result = await dataService.getOwnerMessageTexts(
+ conversationId,
+ batch.map(([id]) => id),
+ );
+ if (cancelled) {
+ return;
+ }
+ for (const message of result.messages) {
+ const match = expected.get(message.messageId);
+ if (
+ match?.revision === message.revision &&
+ match.text === message.canonicalText &&
+ typeof message.text === 'string'
+ ) {
+ const original = {
+ revision: message.revision,
+ text: message.text,
+ canonicalText: message.canonicalText,
+ };
+ originals.set(message.messageId, original);
+ cached.current.messages.set(message.messageId, original);
+ }
+ }
+ for (const [id, revision, text] of batch) {
+ if (!originals.has(id)) {
+ missing.set(id, {
+ revision,
+ canonicalText: text,
+ retryOnCompletion: isSubmitting,
+ });
+ }
+ }
+ setState({ scope, messages: new Map(originals), loading: true, retry });
+ } catch {
+ if (cancelled) {
+ return;
+ }
+ for (const [id, revision, text] of batch) {
+ missing.set(id, { revision, canonicalText: text, retryOnCompletion: isSubmitting });
+ }
+ }
+ }
+ },
+ );
+ await Promise.all(workers);
+ if (!cancelled) {
+ setState({ scope, messages: new Map(originals), loading: false, retry });
+ }
+ };
+ void load();
+ return () => {
+ cancelled = true;
+ };
+ }, [scope, selection, conversationId, user?.id, user?.tenantId, isSubmitting, retryAttempt]);
+ const visible = state.scope === scope ? state : empty;
+ return {children};
+}
+
+/** No owner-view data is passed to edit, copy/export, retry, or prompt-building callbacks. */
+export function PrivateText({ message }: { message: TMessage }) {
+ const localize = useLocalize();
+ const state = useContext(OwnerTextContext);
+ const original = state.messages.get(message.messageId);
+ const text =
+ original != null &&
+ original.revision === message.privacyRevision &&
+ original.canonicalText === message.text
+ ? original.text
+ : undefined;
+ return (
+
+
+
+
+
+ {localize('com_ui_private_text_hidden')}
+ {text == null && (
+
+ {' '}
+ ·{' '}
+ {localize(
+ state.loading ? 'com_ui_private_text_loading' : 'com_ui_private_text_unavailable',
+ )}
+
+ )}
+
+ {text == null && !state.loading && state.retry != null && (
+
+ )}
+
+ );
+}
diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx
index 37a3a9f8d07..0d5dd896842 100644
--- a/client/src/components/Chat/Messages/ui/MessageRender.tsx
+++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx
@@ -15,6 +15,7 @@ import { getHeaderHoverLabel } from '~/components/Chat/Messages/ui/HeaderLabel';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
+import { PrivateText } from '~/components/Chat/Messages/PrivateText';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
@@ -153,6 +154,8 @@ const MessageRender = memo(function MessageRender({
return null;
}
+ const showOwnerText = !edit && msg.isCreatedByUser && Boolean(msg.privacyRevision);
+
return (
- {wakeupDisplay != null && !edit ? (
-
- ) : (
- ({}))}
- />
- )}
+ {showOwnerText && }
+ {!showOwnerText &&
+ (wakeupDisplay != null && !edit ? (
+
+ ) : (
+ ({}))}
+ />
+ ))}
);
diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx
index 8207d594402..6d3facb10bf 100644
--- a/client/src/components/Messages/ContentRender.tsx
+++ b/client/src/components/Messages/ContentRender.tsx
@@ -18,6 +18,7 @@ import { ErrorSourceProvider } from '~/components/Messages/Content/Error/source'
import Elapsed, { shouldShowElapsed } from '~/components/Chat/Messages/Elapsed';
import { getHeaderHoverLabel } from '~/components/Chat/Messages/ui/HeaderLabel';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
+import { PrivateText } from '~/components/Chat/Messages/PrivateText';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
@@ -221,26 +222,30 @@ const ContentRender = memo(function ContentRender({
>
- }
- />
+ {!edit && msg.isCreatedByUser && msg.privacyRevision ? (
+
+ ) : (
+ }
+ />
+ )}
{/** A turn that ran out of agent steps is incomplete, not broken. Rendered
diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts
index 18bb4fa29f8..3dc1e86ab34 100644
--- a/client/src/hooks/Chat/useChatFunctions.ts
+++ b/client/src/hooks/Chat/useChatFunctions.ts
@@ -57,7 +57,14 @@ const STALE_SEND_REVALIDATION_MS = 5_000;
const logChatRequest = (request: Record) => {
logger.log('=====================================\nAsk function called with:');
- logger.dir(request);
+ logger.dir({
+ conversationId: request.conversationId,
+ messageId: request.messageId,
+ parentMessageId: request.parentMessageId,
+ isEdited: request.isEdited,
+ isContinued: request.isContinued,
+ isRegenerate: request.isRegenerate,
+ });
logger.log('=====================================');
};
@@ -799,7 +806,14 @@ export default function useChatFunctions({
);
setSubmissionStart(Date.now());
setSubmission(submission);
- logger.dir('message_stream', submission, { depth: null });
+ logger.dir('message_stream', {
+ conversationId,
+ messageId: currentMsg.messageId,
+ parentMessageId: currentMsg.parentMessageId,
+ isEdited: isEditOrContinue,
+ isRegenerate: regenerateShaped,
+ isContinued,
+ });
};
const regenerate = (
diff --git a/client/src/hooks/Conversations/__tests__/useExportConversation.spec.tsx b/client/src/hooks/Conversations/__tests__/useExportConversation.spec.tsx
new file mode 100644
index 00000000000..af3dfca614e
--- /dev/null
+++ b/client/src/hooks/Conversations/__tests__/useExportConversation.spec.tsx
@@ -0,0 +1,179 @@
+import { Provider } from 'jotai';
+import download from 'downloadjs';
+import exportFromJSON from 'export-from-json';
+import { act, renderHook } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { Constants, QueryKeys } from 'librechat-data-provider';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { TConversation, TMessage } from 'librechat-data-provider';
+import type { ReactNode } from 'react';
+import useExportConversation from '../useExportConversation';
+
+const mockGetMessages = jest.fn();
+const mockShowToast = jest.fn();
+const mockCaptureScreenshot = jest.fn();
+
+jest.mock('librechat-data-provider', () => {
+ const actual = jest.requireActual('librechat-data-provider');
+ return {
+ ...actual,
+ dataService: {
+ ...actual.dataService,
+ getMessagesByConvoId: (...args: unknown[]) => mockGetMessages(...args),
+ },
+ };
+});
+jest.mock('@librechat/client', () => ({
+ ...jest.requireActual('@librechat/client'),
+ useToastContext: () => ({ showToast: mockShowToast }),
+}));
+jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key }));
+jest.mock('~/hooks/ScreenshotContext', () => ({
+ useScreenshot: () => ({ captureScreenshot: mockCaptureScreenshot }),
+ ScreenshotLimitError: class ScreenshotLimitError extends Error {},
+}));
+jest.mock('downloadjs', () => jest.fn());
+jest.mock('export-from-json', () =>
+ Object.assign(jest.fn(), { types: { csv: 'csv', txt: 'txt' } }),
+);
+
+const conversationId = '11111111-1111-4111-8111-111111111111';
+const conversation = { conversationId, title: 'Protected chat' } as TConversation;
+const submitted = {
+ conversationId,
+ messageId: 'user-1',
+ parentMessageId: Constants.NO_PARENT,
+ isCreatedByUser: true,
+ clientTimestamp: '2026-09-28T15:00:00',
+ text: 'Email alice@example.com',
+} as TMessage;
+const canonical = {
+ ...submitted,
+ text: 'Email [EMAIL_1_revision]',
+ privacyRevision: 'revision',
+} as TMessage;
+
+function setup(type: string, pending: TMessage[] = [submitted]) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ queryClient.setQueryData([QueryKeys.messages, conversationId], pending);
+ const hook = renderHook(
+ () =>
+ useExportConversation({
+ conversation,
+ filename: 'protected',
+ type,
+ includeOptions: false,
+ exportBranches: false,
+ recursive: false,
+ }),
+ {
+ wrapper: function Wrapper({ children }: { children: ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ },
+ },
+ );
+ return { ...hook, queryClient };
+}
+
+async function readBlob(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result));
+ reader.onerror = () => reject(reader.error);
+ reader.readAsText(blob);
+ });
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+it('exports acknowledged canonical text rather than a pending private cache value', async () => {
+ mockGetMessages.mockResolvedValueOnce([canonical]);
+ const { result } = setup('json');
+ await act(async () => {
+ await result.current.exportConversation();
+ });
+ expect(mockGetMessages).toHaveBeenCalledWith(conversationId);
+ const saved = (download as jest.Mock).mock.calls[0]?.[0] as Blob;
+ expect(saved).toBeInstanceOf(Blob);
+ const fileText = await readBlob(saved);
+ expect(fileText).toContain(canonical.text);
+ expect(fileText).not.toContain('alice@example.com');
+ expect(mockShowToast).not.toHaveBeenCalled();
+});
+
+it.each(['csv', 'markdown', 'text'])(
+ 'uses the canonical server response for %s exports',
+ async (type) => {
+ mockGetMessages.mockResolvedValueOnce([canonical]);
+ const { result } = setup(type);
+ await act(async () => {
+ await result.current.exportConversation();
+ });
+ expect(mockGetMessages).toHaveBeenCalledWith(conversationId);
+ expect(exportFromJSON).toHaveBeenCalledTimes(1);
+ const payload = JSON.stringify(jest.mocked(exportFromJSON).mock.calls[0][0]);
+ expect(payload).toContain(canonical.text);
+ expect(payload).not.toContain('alice@example.com');
+ },
+);
+
+it('does not fall back to the unfiltered cache when the canonical read fails', async () => {
+ mockGetMessages.mockRejectedValueOnce(new Error('Temporary outage'));
+ const { result } = setup('json');
+ await act(async () => {
+ await result.current.exportConversation();
+ });
+ expect(download).not.toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'com_nav_export_unavailable' }),
+ );
+});
+
+it('does not download a screenshot when a protected row arrives during capture', async () => {
+ const clean = { ...canonical, privacyRevision: undefined, createdAt: '2026-09-28T15:00:01' };
+ let finish!: (result: Blob) => void;
+ mockCaptureScreenshot.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ const { result, queryClient } = setup('screenshot', [clean]);
+ const exportAction = result.current.exportConversation();
+ expect(mockCaptureScreenshot).toHaveBeenCalledTimes(1);
+ act(() => queryClient.setQueryData([QueryKeys.messages, conversationId], [canonical]));
+ finish(new Blob(['captured original'], { type: 'image/png' }));
+ await act(async () => {
+ await exportAction;
+ });
+ expect(download).not.toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'com_nav_export_screenshot_private_text' }),
+ );
+});
+
+it.each([
+ { kind: 'unsent', message: submitted },
+ { kind: 'protected', message: canonical },
+])('does not screenshot $kind text', async ({ message }) => {
+ const { result } = setup('screenshot', [message]);
+ await act(async () => {
+ await result.current.exportConversation();
+ });
+ expect(mockCaptureScreenshot).not.toHaveBeenCalled();
+ expect(download).not.toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'com_nav_export_screenshot_private_text' }),
+ );
+});
diff --git a/client/src/hooks/Conversations/useExportConversation.ts b/client/src/hooks/Conversations/useExportConversation.ts
index 2ea3d31a163..80900ebda53 100644
--- a/client/src/hooks/Conversations/useExportConversation.ts
+++ b/client/src/hooks/Conversations/useExportConversation.ts
@@ -4,10 +4,11 @@ import { useParams } from 'react-router-dom';
import exportFromJSON from 'export-from-json';
import { useToastContext } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query';
-import { buildTree, QueryKeys } from 'librechat-data-provider';
+import { buildTree, Constants, dataService, QueryKeys } from 'librechat-data-provider';
import type { TConversation, TMessage, TPreset } from 'librechat-data-provider';
import { ScreenshotLimitError, useScreenshot } from '~/hooks/ScreenshotContext';
import useBuildMessageTree from '~/hooks/Messages/useBuildMessageTree';
+import { isUnacknowledgedUserMessage } from '~/utils/messages';
import { NotificationSeverity } from '~/common';
import { formatMessageText } from './format';
import { cleanupPreset } from '~/utils';
@@ -42,15 +43,48 @@ export default function useExportConversation({
const { conversationId: paramId } = useParams();
- const getMessageTree = useCallback(() => {
+ const getCachedMessages = useCallback(() => {
const queryParam =
paramId === 'new' ? paramId : (conversation?.conversationId ?? paramId ?? '');
- const messages = queryClient.getQueryData([QueryKeys.messages, queryParam]) ?? [];
+ return queryClient.getQueryData([QueryKeys.messages, queryParam]) ?? [];
+ }, [paramId, conversation?.conversationId, queryClient]);
+
+ const getMessageTree = useCallback(async () => {
+ const conversationId = conversation?.conversationId;
+ if (
+ !conversationId ||
+ conversationId === Constants.NEW_CONVO ||
+ conversationId === Constants.PENDING_CONVO ||
+ conversationId === Constants.SEARCH
+ ) {
+ throw new Error('Conversation is unavailable for export');
+ }
+ const messages = await dataService.getMessagesByConvoId(conversationId);
const dataTree = buildTree({ messages });
return dataTree?.length === 0 ? null : (dataTree ?? null);
- }, [paramId, conversation?.conversationId, queryClient]);
+ }, [conversation?.conversationId]);
+
+ const screenshotWouldExposePrivateText = () =>
+ getCachedMessages().some(
+ (message) => message.privacyRevision != null || isUnacknowledgedUserMessage(message),
+ );
+
+ const refuseUnsafeScreenshot = () => {
+ if (!screenshotWouldExposePrivateText()) {
+ return false;
+ }
+ showToast({
+ message: localize('com_nav_export_screenshot_private_text'),
+ severity: NotificationSeverity.ERROR,
+ showIcon: true,
+ });
+ return true;
+ };
const exportScreenshot = async () => {
+ if (refuseUnsafeScreenshot()) {
+ return;
+ }
let data: Blob;
try {
data = await captureScreenshot();
@@ -67,6 +101,9 @@ export default function useExportConversation({
});
return;
}
+ if (refuseUnsafeScreenshot()) {
+ return;
+ }
download(data, `${filename}.png`, 'image/png');
};
@@ -76,7 +113,7 @@ export default function useExportConversation({
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
- messages: getMessageTree(),
+ messages: await getMessageTree(),
branches: Boolean(exportBranches),
recursive: false,
});
@@ -154,7 +191,7 @@ export default function useExportConversation({
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
- messages: getMessageTree(),
+ messages: await getMessageTree(),
branches: false,
recursive: false,
});
@@ -210,7 +247,7 @@ export default function useExportConversation({
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
- messages: getMessageTree(),
+ messages: await getMessageTree(),
branches: false,
recursive: false,
});
@@ -262,7 +299,7 @@ export default function useExportConversation({
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
- messages: getMessageTree(),
+ messages: await getMessageTree(),
branches: Boolean(exportBranches),
recursive: Boolean(recursive),
});
@@ -279,17 +316,25 @@ export default function useExportConversation({
download(blob, `${filename}.json`, 'application/json');
};
- const exportConversation = () => {
- if (type === 'json') {
- exportJSON();
- } else if (type == 'text') {
- exportText();
- } else if (type == 'markdown') {
- exportMarkdown();
- } else if (type == 'csv') {
- exportCSV();
- } else if (type == 'screenshot') {
- exportScreenshot();
+ const exportConversation = async () => {
+ try {
+ if (type === 'json') {
+ await exportJSON();
+ } else if (type == 'text') {
+ await exportText();
+ } else if (type == 'markdown') {
+ await exportMarkdown();
+ } else if (type == 'csv') {
+ await exportCSV();
+ } else if (type == 'screenshot') {
+ await exportScreenshot();
+ }
+ } catch {
+ showToast({
+ message: localize('com_nav_export_unavailable'),
+ severity: NotificationSeverity.ERROR,
+ showIcon: true,
+ });
}
};
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 5e8dbc38aa9..11a256d368f 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1,4 +1,8 @@
{
+ "com_ui_private_text_hidden": "Private details hidden from the model",
+ "com_ui_private_text_loading": "Loading your original message…",
+ "com_ui_private_text_retry": "Retry loading original",
+ "com_ui_private_text_unavailable": "Original unavailable. Showing the filtered message.",
"com_ui_background_tasks": "Background tasks",
"com_ui_background_tasks_cancel_disabled": "Stopping background tools is not enabled on this server",
"com_ui_background_tasks_checked": "Checked background tasks",
@@ -731,8 +735,10 @@
"com_nav_export_recursive": "Recursive",
"com_nav_export_recursive_or_sequential": "Recursive or sequential?",
"com_nav_export_screenshot_error": "Couldn't capture the screenshot. Try a different export type.",
+ "com_nav_export_screenshot_private_text": "Screenshots can't be exported while a chat contains protected or unsent text. Export as text or JSON instead.",
"com_nav_export_screenshot_too_large": "This conversation is too large to export as a screenshot. Try a different export type.",
"com_nav_export_type": "Type",
+ "com_nav_export_unavailable": "Couldn't load this conversation for export. Try again.",
"com_nav_external": "External",
"com_nav_font_size": "Message Font Size",
"com_nav_font_size_base": "Medium",
diff --git a/client/src/utils/messages.spec.ts b/client/src/utils/messages.spec.ts
index b06f3e5a485..33f17b5d38a 100644
--- a/client/src/utils/messages.spec.ts
+++ b/client/src/utils/messages.spec.ts
@@ -1,6 +1,10 @@
import { ContentTypes } from 'librechat-data-provider';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
-import { preserveStreamedContentIdentity, stripStreamedIndexStamps } from './messages';
+import {
+ preserveStreamedContentIdentity,
+ isUnacknowledgedUserMessage,
+ stripStreamedIndexStamps,
+} from './messages';
const text = (value: string, extra: Record = {}): TMessageContentParts =>
({ type: ContentTypes.TEXT, text: value, ...extra }) as TMessageContentParts;
@@ -20,6 +24,34 @@ const label = (value: string, extra: Record = {}): TMessageCont
const streamedIndexes = (content: TMessage['content']): Array =>
(content ?? []).map((part) => part?.streamedIndex);
+describe('private screenshot admission', () => {
+ it('identifies unsent user text without classifying acknowledged messages as pending', () => {
+ const optimistic = {
+ messageId: 'pending',
+ isCreatedByUser: true,
+ clientTimestamp: '2026-09-28T15:00:00',
+ text: 'alice@example.com',
+ } as TMessage;
+ const assistant = {
+ messageId: 'previous-answer',
+ isCreatedByUser: false,
+ text: 'Safe',
+ } as TMessage;
+ const canonical = {
+ ...optimistic,
+ privacyRevision: 'accepted-revision',
+ text: '[EMAIL_1_accepted-revision]',
+ };
+ const persisted = { ...optimistic, createdAt: '2026-09-28T15:00:01' };
+
+ expect(isUnacknowledgedUserMessage(optimistic)).toBe(true);
+ expect(isUnacknowledgedUserMessage(canonical)).toBe(false);
+ expect(isUnacknowledgedUserMessage(persisted)).toBe(false);
+ expect(isUnacknowledgedUserMessage(assistant)).toBe(false);
+ expect(optimistic.text).toBe('alice@example.com');
+ });
+});
+
describe('preserveStreamedContentIdentity', () => {
it('stamps every part shifted by compacted holes with its streamed index', () => {
const streamed = [
diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts
index 02e05d5f74e..6ef5efc344f 100644
--- a/client/src/utils/messages.ts
+++ b/client/src/utils/messages.ts
@@ -21,6 +21,16 @@ import type { LocalizeFunction, TMessageProps } from '~/common';
export const TEXT_KEY_DIVIDER = '|||';
export const STREAM_START_FAILED_METADATA_KEY = 'streamStartFailed';
+/** A locally submitted user row is not canonical until the server acknowledges it. */
+export function isUnacknowledgedUserMessage(message: TMessage): boolean {
+ return (
+ message.isCreatedByUser === true &&
+ message.clientTimestamp != null &&
+ message.createdAt == null &&
+ !message.privacyRevision
+ );
+}
+
type SiblingIndexLookup = (parentMessageId: string | null | undefined) => number;
export type BranchSiblingIndex = {
@@ -852,6 +862,7 @@ export function areMessageFieldsEqual(
return (
prevMsg.messageId === nextMsg.messageId &&
prevMsg.text === nextMsg.text &&
+ prevMsg.privacyRevision === nextMsg.privacyRevision &&
prevMsg.error === nextMsg.error &&
prevMsg.unfinished === nextMsg.unfinished &&
/** Read by the row: `useGenerationsByLatest` gates the Continue button on it and
diff --git a/e2e/setup/fake-model.js b/e2e/setup/fake-model.js
index 30043194c50..b8119f3afaa 100644
--- a/e2e/setup/fake-model.js
+++ b/e2e/setup/fake-model.js
@@ -3099,6 +3099,22 @@ function resolveResponses({ graph, messages, text, toolNames }) {
return askUserQuestionResponses(askUserQuestionLabel, toolNames);
}
+ if (text.includes('E2E_PRIVATE_TEXT:')) {
+ return {
+ responses: [MOCK_REPLY],
+ resolveOnStream: (streamMessages) => {
+ const prompt = JSON.stringify(streamMessages);
+ const protectedText =
+ !prompt.includes('alice@example.com') && /EMAIL_1_[a-f0-9]{32}/.test(prompt);
+ return {
+ responses: [
+ protectedText ? 'E2E private model input verified' : 'E2E private model input failed',
+ ],
+ };
+ },
+ };
+ }
+
if (text.includes(ASSERT_AGENT_CONTEXT_MARKER)) {
return {
responses: [MOCK_REPLY],
diff --git a/e2e/specs/mock/private-text.spec.ts b/e2e/specs/mock/private-text.spec.ts
new file mode 100644
index 00000000000..94dc7ef37cd
--- /dev/null
+++ b/e2e/specs/mock/private-text.spec.ts
@@ -0,0 +1,268 @@
+import { readFile } from 'node:fs/promises';
+import { expect, test } from '@playwright/test';
+import type { TMessage } from 'librechat-data-provider';
+import { withMongo } from './db';
+import {
+ loginAdmin,
+ setRuntimeFilters,
+ restoreRuntimeFilters,
+ requestResult,
+} from './content-filters.helpers';
+import {
+ MOCK_ENDPOINTS,
+ selectMockEndpoint,
+ sendMessage,
+ sendMessageAndWaitForCompletion,
+ messagesView,
+ fetchJson,
+} from './helpers';
+
+const original = 'E2E_PRIVATE_TEXT: alice@example.com';
+
+test('owner sees original after reload while provider, sharing, and canonical reads stay filtered', async ({
+ page,
+ request,
+}, testInfo) => {
+ test.setTimeout(120000);
+ const token = await loginAdmin(request);
+ await setRuntimeFilters(request, token, {
+ messages: {
+ pii: {
+ action: 'redact',
+ fields: ['text'],
+ starterPatterns: [],
+ customPatterns: [
+ { id: 'email', label: 'Email', regex: 'alice@example\\.com', category: 'email' },
+ ],
+ },
+ },
+ });
+ let conversationId: string | undefined;
+ try {
+ await page.goto('/c/new');
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+ const response = await sendMessageAndWaitForCompletion(page, original);
+ conversationId = (await response.json()).conversationId as string;
+ expect(conversationId).toBeTruthy();
+ await expect(
+ messagesView(page).getByText('E2E private model input verified', { exact: true }),
+ ).toBeVisible();
+ const ownerText = messagesView(page).getByText(original, { exact: true });
+ await expect(ownerText).toBeVisible();
+ const standardContainer = ownerText.locator(
+ 'xpath=ancestor::div[contains(@class,"text-message")][1]',
+ );
+ await expect(standardContainer).toHaveAttribute('dir', 'auto');
+ await expect(
+ messagesView(page).getByText('Private details hidden from the model', { exact: true }),
+ ).toBeVisible();
+
+ const replay = await requestResult(request, {
+ path: new URL(response.url()).pathname,
+ token,
+ method: 'POST',
+ data: response.request().postDataJSON(),
+ });
+ expect(replay.ok).toBe(true);
+ expect((replay.body as { conversationId: string }).conversationId).toBe(conversationId);
+
+ const canonical = await fetchJson(page, `/api/messages/${conversationId}`, token);
+ expect(JSON.stringify(canonical)).not.toContain('alice@example.com');
+ expect(JSON.stringify(canonical)).not.toContain('privateText');
+ const user = canonical.find((message) => message.isCreatedByUser)!;
+ expect(user.text).toMatch(/\[EMAIL_1_[a-f0-9]{32}\]/);
+ await withMongo(async (db) => {
+ const row = await db
+ .collection('messages')
+ .findOne({ conversationId, messageId: user.messageId });
+ expect(row?.privateText).toMatch(/^v1:/);
+ expect(JSON.stringify(row)).not.toContain('alice@example.com');
+ });
+
+ await page.reload();
+ await expect(messagesView(page).getByText(original, { exact: true })).toBeVisible();
+ await expect(
+ ownerText.locator('xpath=ancestor::div[contains(@class,"text-message")][1]'),
+ ).toHaveAttribute('dir', 'auto');
+ for (const theme of ['light', 'dark']) {
+ await page.evaluate(
+ (dark) => document.documentElement.classList.toggle('dark', dark),
+ theme === 'dark',
+ );
+ await page.screenshot({
+ path: testInfo.outputPath(`owner-text-${theme}.png`),
+ fullPage: true,
+ });
+ }
+ const share = await requestResult(request, {
+ path: `/api/share/${conversationId}`,
+ token,
+ method: 'POST',
+ data: {},
+ });
+ expect(share.ok).toBe(true);
+ const shared = await requestResult(request, {
+ path: `/api/share/${(share.body as { shareId: string }).shareId}`,
+ token,
+ });
+ expect(shared.ok).toBe(true);
+ expect(shared.text).not.toContain('alice@example.com');
+ expect(shared.text).not.toContain('privateText');
+ expect(shared.text).toContain('EMAIL_1_');
+
+ const openExport = async () => {
+ await page.getByRole('button', { name: 'Export/Share' }).click();
+ await page.getByRole('menuitem', { name: 'Export' }).click();
+ return page.getByRole('dialog', { name: 'Export conversation' });
+ };
+ const selectType = async (label: string) => {
+ const dialog = page.getByRole('dialog', { name: 'Export conversation' });
+ await dialog.getByTestId('dropdown-menu').click();
+ await page.getByRole('option', { name: label }).click();
+ return dialog;
+ };
+ let exportDialog = await openExport();
+ exportDialog = await selectType('json (.json)');
+ const [canonicalDownload] = await Promise.all([
+ page.waitForEvent('download'),
+ exportDialog.getByRole('button', { name: 'Export', exact: true }).click(),
+ ]);
+ const exported = await readFile(await canonicalDownload.path(), 'utf8');
+ expect(exported).not.toContain('alice@example.com');
+ expect(exported).toContain('EMAIL_1_');
+
+ exportDialog = await selectType('screenshot (.png)');
+ await exportDialog.getByRole('button', { name: 'Export', exact: true }).click();
+ await expect(
+ page
+ .getByText("Screenshots can't be exported while a chat contains protected or unsent text.")
+ .first(),
+ ).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(exportDialog).toBeHidden();
+
+ const pending = 'Unacknowledged private export: alice@example.com';
+ const delayedPath = '**/api/agents/chat/**';
+ let releasePending!: () => void;
+ const pendingGate = new Promise((resolve) => {
+ releasePending = resolve;
+ });
+ await page.route(delayedPath, async (route) => {
+ if (route.request().method() !== 'POST' || route.request().postDataJSON()?.text !== pending) {
+ await route.continue();
+ return;
+ }
+ await pendingGate;
+ await route.continue();
+ });
+ const pendingRequest = (requestToCheck: {
+ method(): string;
+ url(): string;
+ postDataJSON(): { text?: string };
+ }) =>
+ requestToCheck.method() === 'POST' &&
+ new URL(requestToCheck.url()).pathname.startsWith('/api/agents/chat/') &&
+ requestToCheck.postDataJSON()?.text === pending;
+ const requestSeen = page.waitForRequest(pendingRequest);
+ const pendingResponse = page.waitForResponse((result) => pendingRequest(result.request()));
+ try {
+ const input = page.getByRole('textbox', { name: 'Message input' });
+ await input.fill(pending);
+ await input.press('Enter');
+ await requestSeen;
+ exportDialog = await openExport();
+ exportDialog = await selectType('json (.json)');
+ const [pendingDownload] = await Promise.all([
+ page.waitForEvent('download'),
+ exportDialog.getByRole('button', { name: 'Export', exact: true }).click(),
+ ]);
+ const pendingExport = await readFile(await pendingDownload.path(), 'utf8');
+ expect(pendingExport).not.toContain(pending);
+ expect(pendingExport).not.toContain('alice@example.com');
+ expect(pendingExport).toContain('EMAIL_1_');
+ } finally {
+ releasePending();
+ await pendingResponse.catch(() => undefined);
+ await page.unroute(delayedPath);
+ }
+ expect((await pendingResponse).ok()).toBe(true);
+ await expect
+ .poll(
+ async () =>
+ (await fetchJson(page, `/api/messages/${conversationId}`, token)).length,
+ )
+ .toBeGreaterThanOrEqual(4);
+
+ const unauthorized = await request.post(`/api/messages/${conversationId}/owner-text`, {
+ data: { messageIds: [user.messageId] },
+ });
+ expect(unauthorized.status()).toBe(401);
+ } finally {
+ await restoreRuntimeFilters(request, token);
+ if (conversationId) {
+ await requestResult(request, {
+ path: '/api/convos',
+ token,
+ method: 'DELETE',
+ data: { arg: { conversationId } },
+ });
+ await withMongo(async (db) => {
+ expect(await db.collection('messages').countDocuments({ conversationId })).toBe(0);
+ });
+ }
+ }
+});
+
+test('the first protected owner view loads from its server ID while generation is streaming', async ({
+ page,
+ request,
+}) => {
+ test.setTimeout(90000);
+ const token = await loginAdmin(request);
+ await setRuntimeFilters(request, token, {
+ messages: {
+ pii: {
+ action: 'redact',
+ fields: ['text'],
+ starterPatterns: [],
+ customPatterns: [
+ { id: 'email', label: 'Email', regex: 'alice@example\\.com', category: 'email' },
+ ],
+ },
+ },
+ });
+ let conversationId: string | undefined;
+ const text = 'E2E_SLOW_REPLY:owner-stream alice@example.com';
+ try {
+ await page.goto('/c/new');
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+ const ownerRead = page.waitForResponse(
+ (response) =>
+ response.request().method() === 'POST' &&
+ /\/api\/messages\/[^/]+\/owner-text$/.test(new URL(response.url()).pathname),
+ );
+ const started = await sendMessage(page, text);
+ conversationId = (await started.json()).conversationId as string;
+ expect(conversationId).toMatch(/^[0-9a-f-]{36}$/);
+ const owner = await ownerRead;
+ expect(owner.status()).toBe(200);
+ expect(new URL(owner.url()).pathname).toBe(`/api/messages/${conversationId}/owner-text`);
+ expect((await owner.json()).messages[0].text).toBe(text);
+ await expect(
+ messagesView(page).getByText('Private details hidden from the model'),
+ ).toBeVisible();
+ await expect(messagesView(page).getByText(text, { exact: true })).toBeVisible();
+ await expect(page.getByTestId('stop-generation-button')).toBeVisible();
+ await expect(page.getByTestId('stop-generation-button')).toBeHidden({ timeout: 60000 });
+ } finally {
+ await restoreRuntimeFilters(request, token);
+ if (conversationId) {
+ await requestResult(request, {
+ path: '/api/convos',
+ token,
+ method: 'DELETE',
+ data: { arg: { conversationId } },
+ });
+ }
+ }
+});
diff --git a/librechat.example.yaml b/librechat.example.yaml
index 7836c55405b..fd64b904d9b 100644
--- a/librechat.example.yaml
+++ b/librechat.example.yaml
@@ -1396,8 +1396,12 @@ endpoints:
# messages:
# unattributedAssistantContent: model_output # `model_output` (default) or `inspect`
# pii:
-# action: audit # `block` (default), `audit`, or `redact` (reserved for transformation-aware ingress; current routes still block)
-# # `redact` requires the later encrypted owner-view and canonical-message integration.
+# action: audit # `block` (default), `audit`, or opt-in `redact`
+# # `redact` transforms fresh, text-only interactive Agent chat submissions with a clientRequestId.
+# # Edits, resumes, files, quotes, other endpoints, and other fields still use blocking checks.
+# # Requires a valid CREDS_KEY. Preserve that key for owner-original recovery; changing it
+# # makes existing originals unavailable. Text remains canonical/filtered in ordinary exports.
+# # Originals are an encrypted, unindexed sidecar field, deleted/expired with the message.
# # maxCharacters: 65536 # bounded total inspected text per transformation session
# # maxMatches: 256 # bounded matches per transformation session
# # Custom patterns may set category: email, phone, name, credential, or custom.
diff --git a/packages/api/src/middleware/messageFilterPii.ts b/packages/api/src/middleware/messageFilterPii.ts
index a9cd123c052..4d2626e0da0 100644
--- a/packages/api/src/middleware/messageFilterPii.ts
+++ b/packages/api/src/middleware/messageFilterPii.ts
@@ -147,6 +147,7 @@ export interface CreateMessageFilterPiiOptions {
getConfig: (req: ServerRequest) => MessageFilterPiiConfig | undefined;
getFilters?: (req: ServerRequest) => FiltersConfig | undefined;
getFiles?: GetCanonicalFilesForInspection;
+ getPreinspectedText?: (req: ServerRequest) => string | undefined;
}
export function createMessageFilterPii(options: CreateMessageFilterPiiOptions): RequestHandler {
@@ -241,7 +242,18 @@ export function createMessageFilterPii(options: CreateMessageFilterPiiOptions):
if (filters != null && !collect(() => extractStoredMessageContent(req.body))) {
return;
}
- const finding = inspectContent(fragments, { filters, legacyPii });
+ const preinspectedText = options.getPreinspectedText?.(req);
+ const inspectable =
+ preinspectedText == null
+ ? fragments
+ : fragments.filter(
+ (fragment) =>
+ fragment.source !== 'message' ||
+ fragment.field !== 'text' ||
+ fragment.path !== '/text' ||
+ fragment.text !== preinspectedText,
+ );
+ const finding = inspectContent(inspectable, { filters, legacyPii });
if (finding != null) {
if (finding.detectorId !== 'legacy-pattern') {
res.status(400).json(contentFilterBlockResponse(finding));
diff --git a/packages/api/src/protection/index.ts b/packages/api/src/protection/index.ts
index 264f49b381b..bfc6d02532b 100644
--- a/packages/api/src/protection/index.ts
+++ b/packages/api/src/protection/index.ts
@@ -10,3 +10,5 @@ export * from './adapters/chat';
export * from './adapters/nested';
export * from './adapters/messages';
export * from './adapters/submissions';
+export * from './private/submission';
+export * from './private/view';
diff --git a/packages/api/src/protection/private/crypto.ts b/packages/api/src/protection/private/crypto.ts
new file mode 100644
index 00000000000..20aa47451cf
--- /dev/null
+++ b/packages/api/src/protection/private/crypto.ts
@@ -0,0 +1,53 @@
+import { createCipheriv, createDecipheriv, createHmac, hkdfSync, randomBytes } from 'node:crypto';
+
+export interface PrivateTextCipher {
+ seal(text: string, binding: readonly string[]): string;
+ open(envelope: string, binding: readonly string[]): string;
+ revision(value: readonly string[]): string;
+}
+
+/** Uses the deployment credential key, domain-separated from credential encryption. */
+export function createPrivateTextCipher(hexKey: string): PrivateTextCipher {
+ if (!/^[a-fA-F0-9]{64}$/.test(hexKey)) {
+ throw new Error('Private message encryption is unavailable.');
+ }
+ const key = Buffer.from(
+ hkdfSync('sha256', Buffer.from(hexKey, 'hex'), '', 'librechat-owner-text-v1', 32),
+ );
+ const aad = (binding: readonly string[]) =>
+ Buffer.from(JSON.stringify(['owner-text-v1', ...binding]));
+ return {
+ revision(value) {
+ return createHmac('sha256', key).update(aad(value)).digest('hex').slice(0, 32);
+ },
+ seal(text, binding) {
+ const nonce = randomBytes(12);
+ const cipher = createCipheriv('aes-256-gcm', key, nonce);
+ cipher.setAAD(aad(binding));
+ const ciphertext = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
+ return [
+ 'v1',
+ nonce.toString('base64'),
+ cipher.getAuthTag().toString('base64'),
+ ciphertext.toString('base64'),
+ ].join(':');
+ },
+ open(envelope, binding) {
+ try {
+ const [version, nonce, tag, ciphertext, extra] = envelope.split(':');
+ if (version !== 'v1' || extra != null || !nonce || !tag || ciphertext == null) {
+ throw new Error();
+ }
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(nonce, 'base64'));
+ decipher.setAAD(aad(binding));
+ decipher.setAuthTag(Buffer.from(tag, 'base64'));
+ return Buffer.concat([
+ decipher.update(Buffer.from(ciphertext, 'base64')),
+ decipher.final(),
+ ]).toString('utf8');
+ } catch {
+ throw new Error('Private message text is unavailable.');
+ }
+ },
+ };
+}
diff --git a/packages/api/src/protection/private/submission.spec.ts b/packages/api/src/protection/private/submission.spec.ts
new file mode 100644
index 00000000000..991a570d804
--- /dev/null
+++ b/packages/api/src/protection/private/submission.spec.ts
@@ -0,0 +1,416 @@
+import type { MessageMethods, IMessage } from '@librechat/data-schemas';
+import type { FiltersConfig } from 'librechat-data-provider';
+import type { Request, Response } from 'express';
+import {
+ createPrivateTextIngress,
+ savePrivateTextMessage,
+ stampPrivateTextMessage,
+ stampPreliminaryPrivateTextMessage,
+ requirePrivateTextPersistence,
+ saveAbortedUserMessage,
+ isPreDenialTextSubmission,
+ isPrivateTextChatSubmission,
+ getPreinspectedPrivateText,
+ privateTextBinding,
+} from './submission';
+import { createMessageFilterPii } from '../../middleware/messageFilterPii';
+import { createPrivateTextCipher } from './crypto';
+import { createPrivateTextView } from './view';
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() },
+}));
+
+const key = 'ab'.repeat(32);
+const original = 'Email alice@example.com';
+const filters: FiltersConfig = {
+ messages: {
+ pii: {
+ action: 'redact',
+ fields: ['text'],
+ starterPatterns: [],
+ customPatterns: [
+ { id: 'email', label: 'Email', regex: '[a-z]+@[a-z]+\\.[a-z]+', category: 'email' },
+ ],
+ },
+ },
+};
+function submit(overrides: object = {}, encryptionKey = key) {
+ const req = {
+ path: '/',
+ user: { id: 'owner', tenantId: 'tenant-a' },
+ body: { text: original, clientRequestId: 'request-1', ...overrides },
+ } as unknown as Request;
+ const res = { status: jest.fn().mockReturnThis(), json: jest.fn(), setHeader: jest.fn() };
+ const next = jest.fn();
+ createPrivateTextIngress({
+ getFilters: () => filters,
+ getLegacyPii: () => undefined,
+ getKey: () => encryptionKey,
+ })(req, res as unknown as Response, next);
+ const message = stampPrivateTextMessage(req, {
+ messageId: 'message-1',
+ conversationId: 'conversation-1',
+ isCreatedByUser: true,
+ text: req.body.text,
+ });
+ return { req, res, next, message };
+}
+
+describe('private text submission boundary', () => {
+ it('limits early processing to interactive POSTs, excluding controls and queued work', () => {
+ for (const path of ['/api/agents/chat', '/api/agents/chat/safe-ephemeral']) {
+ expect(
+ isPrivateTextChatSubmission({
+ method: 'POST',
+ originalUrl: path,
+ body: { text: original },
+ } as Request),
+ ).toBe(true);
+ }
+ for (const path of [
+ '/api/agents/chat/abort',
+ '/api/agents/chat/resume',
+ '/api/agents/chat/queued-turns',
+ '/api/agents/chat/steer',
+ '/api/agents/chat/status/one',
+ ]) {
+ expect(
+ isPrivateTextChatSubmission({
+ method: 'POST',
+ originalUrl: path,
+ body: { text: original },
+ } as Request),
+ ).toBe(false);
+ }
+ expect(
+ isPreDenialTextSubmission({
+ method: 'POST',
+ body: { text: original },
+ } as Request),
+ ).toBe(true);
+ expect(
+ isPrivateTextChatSubmission({
+ method: 'GET',
+ originalUrl: '/api/agents/chat',
+ body: { text: original },
+ } as Request),
+ ).toBe(false);
+ });
+
+ it('preserves the exact encrypted user row on Stop and fails closed when missing', async () => {
+ const saveMessage: MessageMethods['saveMessage'] = jest.fn(
+ async (_ctx, message) => message as IMessage,
+ );
+ const hasPersistedPrivateText = jest.fn(async () => true);
+ const getPrivateMessageTexts = jest.fn(async (): Promise => []);
+ const store = { saveMessage, hasPersistedPrivateText, getPrivateMessageTexts };
+ const { message } = submit();
+ expect(
+ await saveAbortedUserMessage(
+ store,
+ { userId: 'owner' },
+ message,
+ { context: 'Stop' },
+ 'tenant-a',
+ ),
+ ).toBe(true);
+ expect(hasPersistedPrivateText).toHaveBeenCalledWith({
+ userId: 'owner',
+ tenantId: 'tenant-a',
+ conversationId: message.conversationId,
+ messageId: message.messageId,
+ text: message.text,
+ privacyRevision: message.privacyRevision,
+ });
+ expect(saveMessage).not.toHaveBeenCalled();
+ hasPersistedPrivateText.mockResolvedValueOnce(false);
+ await expect(
+ saveAbortedUserMessage(store, { userId: 'owner' }, message, { context: 'Stop' }, 'tenant-a'),
+ ).rejects.toThrow('private value');
+ expect(saveMessage).not.toHaveBeenCalled();
+ expect(
+ await saveAbortedUserMessage(
+ store,
+ { userId: 'owner' },
+ {
+ ...message,
+ text: 'ordinary turn without private values',
+ privacyRevision: undefined,
+ },
+ { context: 'ordinary Stop' },
+ 'tenant-a',
+ ),
+ ).toBe(true);
+ expect(saveMessage).toHaveBeenCalledTimes(1);
+ });
+
+ it('checks old revisionless protected jobs against storage instead of overwriting them', async () => {
+ const { message } = submit();
+ const saveMessage = jest.fn(async () => message as IMessage);
+ const getPrivateMessageTexts = jest.fn(async () => [
+ {
+ messageId: message.messageId!,
+ text: message.text!,
+ privacyRevision: message.privacyRevision!,
+ privateText: 'v1:encrypted',
+ },
+ ]);
+ const store = {
+ saveMessage,
+ hasPersistedPrivateText: jest.fn(async () => true),
+ getPrivateMessageTexts,
+ };
+ const finalEvent = { requestMessage: { messageId: message.messageId, privacyRevision: '' } };
+ const older = { ...message, privacyRevision: undefined };
+ expect(
+ await saveAbortedUserMessage(
+ store,
+ { userId: 'owner' },
+ older,
+ undefined,
+ 'tenant-a',
+ finalEvent,
+ ),
+ ).toBe(true);
+ expect(saveMessage).not.toHaveBeenCalled();
+ expect(finalEvent.requestMessage.privacyRevision).toBe(message.privacyRevision);
+ getPrivateMessageTexts.mockResolvedValueOnce([]);
+ await expect(
+ saveAbortedUserMessage(store, { userId: 'owner' }, older, undefined, 'tenant-a'),
+ ).rejects.toThrow('private value');
+ expect(saveMessage).not.toHaveBeenCalled();
+ });
+
+ it('stamps a protected preliminary job message before the created event', () => {
+ const { req, message } = submit();
+ const preliminary = stampPreliminaryPrivateTextMessage(req, {
+ messageId: message.messageId,
+ conversationId: message.conversationId,
+ text: message.text,
+ });
+ expect(preliminary?.privacyRevision).toBe(message.privacyRevision);
+ expect(JSON.stringify(preliminary)).not.toContain(original);
+ expect(stampPreliminaryPrivateTextMessage(req, null)).toBeNull();
+ });
+
+ it('replaces request text before consumers and exposes no original in metadata or serialization', () => {
+ const { req, message, next } = submit();
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(req.body.text).toMatch(/^Email \[EMAIL_1_[a-f0-9]{32}\]$/);
+ expect(message).toHaveProperty('privacyRevision');
+ expect(JSON.stringify({ req, message })).not.toContain('alice@example.com');
+ });
+
+ it('skips only the verified, preinspected text in the second PII pass', async () => {
+ const patterns: FiltersConfig = {
+ messages: {
+ pii: {
+ action: 'redact',
+ fields: ['text'],
+ starterPatterns: [],
+ customPatterns: [
+ { id: 'email', label: 'Email', regex: 'alice@example\\.com', category: 'email' },
+ { id: 'revision', label: 'Credential', regex: '[a-f0-9]{32}', category: 'credential' },
+ ],
+ },
+ },
+ };
+ const req = {
+ path: '/',
+ user: { id: 'owner', tenantId: 'tenant-a' },
+ body: {
+ text: original,
+ clientRequestId: 'hex-rule-1',
+ input: undefined as string | undefined,
+ },
+ } as unknown as Request;
+ const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+ const ingressNext = jest.fn();
+ createPrivateTextIngress({
+ getFilters: () => patterns,
+ getLegacyPii: () => undefined,
+ getKey: () => key,
+ })(req, res as unknown as Response, ingressNext);
+ expect(ingressNext).toHaveBeenCalledTimes(1);
+ expect(req.body.text).toMatch(/\[EMAIL_1_[a-f0-9]{32}\]/);
+ expect(getPreinspectedPrivateText(req)).toBe(req.body.text);
+
+ const secondPass = createMessageFilterPii({
+ getConfig: () => undefined,
+ getFilters: () => patterns,
+ getPreinspectedText: getPreinspectedPrivateText,
+ });
+ const next = jest.fn();
+ await secondPass(req, res as unknown as Response, next);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(res.status).not.toHaveBeenCalled();
+
+ req.body.input = original;
+ await secondPass(req, res as unknown as Response, next);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(next).toHaveBeenCalledTimes(1);
+ req.body.input = undefined;
+ req.body.text = original;
+ expect(getPreinspectedPrivateText(req)).toBeUndefined();
+ await secondPass(req, res as unknown as Response, next);
+ expect(res.status).toHaveBeenCalledTimes(2);
+ });
+
+ it('uses stable retry revisions and distinct namespaces for different turns or originals', () => {
+ const first = submit().message;
+ expect(submit().message).toEqual(first);
+ expect(submit({ clientRequestId: 'request-2' }).message.text).not.toBe(first.text);
+ expect(submit({ text: 'Email bob@example.com' }).message.text).not.toBe(first.text);
+ });
+
+ it.each([
+ { files: [{}] },
+ { quotes: ['quote'] },
+ { isRegenerate: true },
+ { isEdited: true },
+ { isContinued: true },
+ { editedContent: {} },
+ { recoverySteerId: 'steer' },
+ ])('leaves unsupported submissions to the existing blocking inspector: %j', (extra) => {
+ const { req, next, message } = submit(extra);
+ expect(req.body.text).toBe(original);
+ expect(message).not.toHaveProperty('privacyRevision');
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+
+ it('fails closed without a valid key and does not echo matched text', () => {
+ const { next, res } = submit({}, '');
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(JSON.stringify(res.json.mock.calls)).not.toContain(original);
+ });
+
+ it('commits canonical text and ciphertext together and binds the owner view to final identity', async () => {
+ const { req, message } = submit();
+ let envelope = '';
+ const save: MessageMethods['saveMessage'] = jest.fn(async (_ctx, value, metadata) => {
+ envelope = metadata?.privateText?.envelope ?? '';
+ expect(JSON.stringify({ value, metadata })).not.toContain(original);
+ return { ...value, privacyRevision: metadata?.privateText?.revision } as IMessage;
+ });
+ const stored = await savePrivateTextMessage(save, req, { userId: 'owner' }, message);
+ expect(save).toHaveBeenCalledTimes(1);
+ const cipher = createPrivateTextCipher(key);
+ const binding = privateTextBinding('owner', 'tenant-a', stored!);
+ expect(cipher.open(envelope, binding)).toBe(original);
+ for (let index = 0; index < binding.length; index++) {
+ const tampered = [...binding];
+ tampered[index] += '-changed';
+ expect(() => cipher.open(envelope, tampered)).toThrow('Private message text is unavailable.');
+ }
+ expect(() => createPrivateTextCipher('cd'.repeat(32)).open(envelope, binding)).toThrow();
+ expect(() => cipher.open(envelope.slice(0, -4) + 'abcd', binding)).toThrow();
+ await expect(savePrivateTextMessage(save, req, { userId: 'other' }, message)).rejects.toThrow();
+ });
+
+ it('does not release main provider admission until persistence finishes', async () => {
+ const { req, message } = submit();
+ let finish!: (result: { message: typeof message }) => void;
+ const pending = new Promise<{ message: typeof message }>((resolve) => {
+ finish = resolve;
+ });
+ const provider = jest.fn();
+ const started = requirePrivateTextPersistence(req, () => pending).then(provider);
+ await Promise.resolve();
+ expect(provider).not.toHaveBeenCalled();
+ finish({ message });
+ await started;
+ expect(provider).toHaveBeenCalledTimes(1);
+ await expect(requirePrivateTextPersistence(req, async () => ({}))).rejects.toThrow();
+ await expect(
+ requirePrivateTextPersistence(req, async () => {
+ throw new Error('write failed');
+ }),
+ ).rejects.toThrow();
+ });
+
+ it('rejects stale or swallowed persistence results, including a duplicate ID with different text', async () => {
+ const { req, message } = submit();
+ const save: MessageMethods['saveMessage'] = jest.fn(async () => undefined);
+ await expect(savePrivateTextMessage(save, req, { userId: 'owner' }, message)).rejects.toThrow();
+ await expect(
+ requirePrivateTextPersistence(req, async () => ({ message: { ...message, text: 'stale' } })),
+ ).rejects.toThrow();
+ });
+
+ it('returns original text only from the authenticated private view, with no-store headers', async () => {
+ const { req, message } = submit();
+ const cipher = createPrivateTextCipher(key);
+ const row = {
+ ...message,
+ privacyRevision: message.privacyRevision!,
+ privateText: cipher.seal(original, privateTextBinding('owner', 'tenant-a', message)),
+ };
+ const read = jest.fn(async () => [row]);
+ const handler = createPrivateTextView({ read, getKey: () => key });
+ req.params = { conversationId: 'conversation-1' };
+ req.body = { messageIds: ['message-1'] };
+ const res = { status: jest.fn().mockReturnThis(), json: jest.fn(), setHeader: jest.fn() };
+ await handler(req, res as unknown as Response, jest.fn());
+ expect(read).toHaveBeenCalledWith({
+ userId: 'owner',
+ tenantId: 'tenant-a',
+ conversationId: 'conversation-1',
+ messageIds: ['message-1'],
+ });
+ expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'private, no-store');
+ expect(res.json).toHaveBeenCalledWith({
+ messages: [
+ {
+ messageId: 'message-1',
+ revision: row.privacyRevision,
+ canonicalText: row.text,
+ text: original,
+ },
+ ],
+ });
+ req.body.messageIds = Array(51).fill('message-1');
+ await handler(req, res as unknown as Response, jest.fn());
+ expect(res.status).toHaveBeenLastCalledWith(400);
+ expect(read).toHaveBeenCalledTimes(1);
+ await handler(
+ { params: req.params, body: { messageIds: ['message-1'] } } as Request,
+ res as unknown as Response,
+ jest.fn(),
+ );
+ expect(res.status).toHaveBeenLastCalledWith(401);
+ expect(read).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns no original when a stored row has changed canonical text or the key is unavailable', async () => {
+ const { message, req } = submit();
+ const cipher = createPrivateTextCipher(key);
+ const privateText = cipher.seal(original, privateTextBinding('owner', 'tenant-a', message));
+ const read = jest.fn(async () => [
+ {
+ ...message,
+ text: 'edited canonical',
+ privacyRevision: message.privacyRevision!,
+ privateText,
+ },
+ ]);
+ req.params = { conversationId: 'conversation-1' };
+ req.body = { messageIds: ['message-1'] };
+ const res = { status: jest.fn().mockReturnThis(), json: jest.fn(), setHeader: jest.fn() };
+ await createPrivateTextView({ read, getKey: () => key })(
+ req,
+ res as unknown as Response,
+ jest.fn(),
+ );
+ expect(JSON.stringify(res.json.mock.calls)).not.toContain(original);
+ expect(res.status).toHaveBeenLastCalledWith(200);
+ await createPrivateTextView({ read, getKey: () => '' })(
+ req,
+ res as unknown as Response,
+ jest.fn(),
+ );
+ expect(res.status).toHaveBeenLastCalledWith(503);
+ expect(JSON.stringify(res.json.mock.calls)).not.toContain(original);
+ });
+});
diff --git a/packages/api/src/protection/private/submission.ts b/packages/api/src/protection/private/submission.ts
new file mode 100644
index 00000000000..2be510516a8
--- /dev/null
+++ b/packages/api/src/protection/private/submission.ts
@@ -0,0 +1,441 @@
+import type { FiltersConfig, MessageFilterPiiConfig } from 'librechat-data-provider';
+import type { RequestHandler, Request, Response } from 'express';
+import type { MessageMethods } from '@librechat/data-schemas';
+import type { PrivateTextCipher } from './crypto';
+import { ContentFilterError } from '../../middleware/contentFilter';
+import { createPiiTextTransformer } from '../transform';
+import { createPrivateTextCipher } from './crypto';
+import { inspectContent } from '../runtime';
+
+interface PrivateTextMessage {
+ messageId?: string;
+ conversationId?: string | null;
+ isCreatedByUser?: boolean;
+ text?: string;
+ privacyRevision?: string;
+}
+
+interface Capture {
+ readonly userId: string;
+ readonly tenantId: string;
+ readonly revision: string;
+ readonly text: string;
+ readonly envelope: string;
+ readonly cipher: PrivateTextCipher;
+}
+
+const captures = new WeakMap