fix: preserve earlier message and file expiries under forced retention

Two leftover paths still extended data that was already scheduled to
expire sooner when forcing ephemeral retention:

- forceConversationMessagesTemporary (and the by-tag message backfill)
  rewrote every gap-matched message to the forced deadline, overwriting
  carried-over `all`-mode messages whose own per-message TTL was already
  sooner. Use a $min aggregation update so each message keeps its earlier
  deadline while still being marked temporary and capped if later.
- File uploads under ephemeral retention skipped the conversation lookup
  and got a fresh TTL, so a new attachment to a chat with a preserved
  earlier expiry could outlive the TTL-deleted conversation. Cap ephemeral
  file expiry to the minimum of the fresh window and the active parent
  conversation expiry, mirroring the message and shared-link paths.
This commit is contained in:
Marco Beretta 2026-06-24 18:23:47 +02:00
parent acd150d4af
commit 75110c70e5
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 110 additions and 6 deletions

View file

@ -52,14 +52,41 @@ describe('retention helpers', () => {
expect(dependencies.getConvo).not.toHaveBeenCalled();
});
it('returns expiry when retentionMode is EPHEMERAL', async () => {
it('returns a fresh expiry when retentionMode is EPHEMERAL and the conversation has no earlier deadline', async () => {
dependencies.getConvo.mockResolvedValue(null);
const result = await getRetentionExpiry(
request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
dependencies,
);
expect(result).toEqual({ expiredAt: expirationDate });
expect(dependencies.getConvo).toHaveBeenCalledWith('user-1', 'convo-1');
});
it('caps an ephemeral file to a parent conversation that expires sooner', async () => {
const soonerExpiry = new Date('2029-01-01T00:00:00.000Z');
dependencies.getConvo.mockResolvedValue({ expiredAt: soonerExpiry });
const result = await getRetentionExpiry(
request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
dependencies,
);
expect(result).toEqual({ expiredAt: soonerExpiry });
});
it('keeps the fresh window when an ephemeral parent expires later', async () => {
dependencies.getConvo.mockResolvedValue({
expiredAt: new Date('2031-01-01T00:00:00.000Z'),
});
const result = await getRetentionExpiry(
request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
dependencies,
);
expect(result).toEqual({ expiredAt: expirationDate });
expect(dependencies.getConvo).not.toHaveBeenCalled();
});
it('returns a fresh expiry when the conversation has an active expiration', async () => {
@ -322,7 +349,7 @@ describe('retention helpers', () => {
);
expect(result).toEqual({ expiredAt: expirationDate });
expect(dependencies.getConvo).not.toHaveBeenCalled();
expect(dependencies.getConvo).toHaveBeenCalledWith('user-1', 'convo-1');
expect(dependencies.createExpirationDate).toHaveBeenCalledTimes(1);
});

View file

@ -99,11 +99,50 @@ const getRetentionCacheKey = (req: RetentionRequest): string =>
String(req.body?.isTemporary ?? ''),
].join('|');
/**
* Resolves a forced (ephemeral) file deadline. The file is always retained, but capped to the
* minimum of a freshly created window and the parent conversation's active expiry, so a new
* attachment cannot linger in files/storage after the conversation and messages are TTL-deleted.
*/
async function computeForcedRetentionExpiry(
req: RetentionRequest | null | undefined,
dependencies: RetentionDependencies,
): Promise<RetentionExpiry> {
const fresh = createRetentionExpiry(req, dependencies);
const conversationId = req?.body?.conversationId;
const userId = req?.user?.id;
if (!conversationId || !userId) {
return fresh;
}
try {
const convo = await dependencies.getConvo(userId, conversationId);
const conversationExpiredAt = getConversationExpirationDate(convo);
if (conversationExpiredAt == null) {
return fresh;
}
if (!isActiveExpirationDate(conversationExpiredAt)) {
return { expiredAt: conversationExpiredAt };
}
if (fresh.expiredAt != null && conversationExpiredAt < fresh.expiredAt) {
return { expiredAt: conversationExpiredAt };
}
return fresh;
} catch (err) {
dependencies.logger?.error('[getRetentionExpiry] Error checking conversation retention:', err);
return fresh;
}
}
async function computeRetentionExpiry(
req: RetentionRequest | null | undefined,
dependencies: RetentionDependencies,
): Promise<RetentionExpiry> {
if (isAllDataRetention(req?.config?.interfaceConfig?.retentionMode)) {
const retentionMode = req?.config?.interfaceConfig?.retentionMode;
if (retentionMode === RetentionMode.EPHEMERAL) {
return computeForcedRetentionExpiry(req, dependencies);
}
if (isAllDataRetention(retentionMode)) {
return createRetentionExpiry(req, dependencies);
}

View file

@ -837,6 +837,39 @@ describe('Message Operations', () => {
}
});
it('preserves a carried-over message that already expires sooner than the forced window', async () => {
const conversationId = uuidv4();
const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Permanent chat with a sooner-expiring message',
isTemporary: false,
});
const soonerMessageId = uuidv4();
await Message.create({
messageId: soonerMessageId,
conversationId,
user: 'user123',
text: 'all-mode message with its own sooner TTL',
isTemporary: false,
expiredAt: soonerExpiry,
});
await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'follow-up', user: 'user123' },
);
const sooner = await Message.findOne({ messageId: soonerMessageId }).lean();
expect(sooner?.isTemporary).toBe(true);
expect(sooner?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
});
it('re-caps an already temporary parent and its messages to a shorter ephemeral window', async () => {
const conversationId = uuidv4();
const longerExpiry = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);

View file

@ -94,6 +94,11 @@ export const capForcedRetentionExpiry = (
* whose `expiredAt` is later than a newly shortened window that would otherwise outlive
* the converted conversation. The gap filter pulls all of them onto the ephemeral schedule
* and stays a no-op once a conversation already conforms.
*
* Each message keeps its own earlier deadline: a carried-over message whose per-message TTL
* already expires sooner than the forced window is marked temporary but its `expiredAt` is
* left untouched (`$min`), so converting the conversation never extends data that was already
* scheduled to expire sooner.
*/
export const forceConversationMessagesTemporary = async (
Message: Model<IMessage>,
@ -103,7 +108,7 @@ export const forceConversationMessagesTemporary = async (
): Promise<void> => {
await Message.updateMany(
{ conversationId, user: userId, ...forcedRetentionGapFilter<IMessage>(expiredAt) },
{ $set: { isTemporary: true, expiredAt } },
[{ $set: { isTemporary: true, expiredAt: { $min: ['$expiredAt', expiredAt] } } }],
);
};
@ -245,7 +250,7 @@ export const cascadeForcedRetentionByTag = async (
conversationId: { $in: conversationIds },
...forcedRetentionGapFilter<IMessage>(expiredAt),
},
{ $set: { isTemporary: true, expiredAt } },
[{ $set: { isTemporary: true, expiredAt: { $min: ['$expiredAt', expiredAt] } } }],
);
await SharedLink.updateMany(
{