From 61016e328ac0c4722ec17d5e8062335bf7a8d02a Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:27:01 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=84=20feat:=20Continue=20Shared=20Conv?= =?UTF-8?q?ersations=20as=20Personal=20Copies=20(#13714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Continue this chat" button to the shared conversation view that forks the shared conversation into a new conversation owned by the viewer and opens it to continue (issue #13001). - POST /api/share/:shareId/fork, gated by requireJwtAuth, the fork rate limiters, and the canAccessSharedLink ACL (view access = fork access). - forkSharedConversation clones from the anonymized getSharedMessages payload, so only share-visible data is copied. - Strips file ids from cloned files/attachments so a fork grants no more file access than viewing the read-only share, and honors the global shared-file kill switch via the snapshotFiles option. - Reduces the clone to the viewer's active branch, located by its index in the shared payload (shared ids are re-anonymized per request and createdAt can collide, while the payload order is stable). - Resolves config/retention, persists, and reads back under the requesting user's tenant, not the share owner's; canAccessSharedLink also falls back to a system-wide share lookup so cross-tenant public shares resolve (ACL still enforced under the share's own tenant). - Resolves a usable endpoint/model from the viewer's models config instead of hard-coding OpenAI, so deployments without OpenAI can send the first message. - Routes the fork's 401s (logged-out or cold-loaded viewers) through login, including when the refresh itself is rejected for a stale session. - Hides the Temporary Chat toggle once a conversation has a real id, and portals the share-settings theme/language dropdowns above the dialog. Rebased onto dev; collapses the share-fork feature and its review fixes into a single commit. --- api/server/routes/__tests__/share.spec.js | 83 +++- api/server/routes/share.js | 34 ++ api/server/utils/import/defaults.js | 75 ++++ api/server/utils/import/defaults.spec.js | 83 ++++ api/server/utils/import/fork.js | 147 ++++++- api/server/utils/import/fork.spec.js | 365 ++++++++++++++++++ client/src/components/Chat/TemporaryChat.tsx | 5 + .../Nav/SettingsTabs/General/Selectors.tsx | 14 +- client/src/components/Share/ShareView.tsx | 178 +++++++-- client/src/data-provider/mutations.ts | 35 ++ client/src/locales/en/translation.json | 2 + packages/api/src/shared-links/access.test.ts | 32 +- packages/api/src/shared-links/access.ts | 11 +- .../specs/request-interceptor.spec.ts | 70 ++++ packages/data-provider/src/api-endpoints.ts | 1 + packages/data-provider/src/data-service.ts | 7 + packages/data-provider/src/request.ts | 23 +- packages/data-provider/src/types.ts | 8 + packages/data-provider/src/types/mutations.ts | 5 + 19 files changed, 1122 insertions(+), 56 deletions(-) diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index c9d6cb0037..253fa93fd8 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -7,7 +7,10 @@ const mockGrantCreationPermissions = jest.fn(); const mockUpdateSharedLinkPermissionsExpiration = jest.fn(); const mockSharedLinksAccess = jest.fn((_req, _res, next) => next()); const mockBuildSharedLinkStartupPayload = jest.fn(); -const mockCanAccessSharedLink = jest.fn((_req, _res, next) => next()); +const mockCanAccessSharedLink = jest.fn((req, _res, next) => { + req.shareResourceId = 'resource-123'; + next(); +}); const mockGetAppConfig = jest.fn(); const mockGetTenantId = jest.fn(() => undefined); @@ -105,6 +108,17 @@ jest.mock('~/server/services/Config/app', () => ({ getAppConfig: (...args) => mockGetAppConfig(...args), })); +jest.mock('~/server/middleware/limiters', () => ({ + createForkLimiters: () => ({ + forkIpLimiter: (_req, _res, next) => next(), + forkUserLimiter: (_req, _res, next) => next(), + }), +})); + +jest.mock('~/server/utils/import/fork', () => ({ + forkSharedConversation: jest.fn(), +})); + const { Readable } = require('stream'); const { RetentionMode } = require('librechat-data-provider'); const { createTempChatExpirationDate, logger } = require('@librechat/data-schemas'); @@ -123,6 +137,7 @@ const { backfillSharedLinkFiles, getRoleByName, } = require('~/models'); +const { forkSharedConversation } = require('~/server/utils/import/fork'); const shareRouter = require('../share'); const activeExpiration = new Date('2030-01-01T00:00:00.000Z'); @@ -132,11 +147,11 @@ const lean = (value) => ({ lean: jest.fn().mockResolvedValue(value), }); -const buildApp = ({ retentionMode = RetentionMode.TEMPORARY } = {}) => { +const buildApp = ({ retentionMode = RetentionMode.TEMPORARY, user = { id: 'user-123' } } = {}) => { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - req.user = { id: 'user-123' }; + req.user = user; req.config = { interfaceConfig: { retentionMode } }; next(); }); @@ -506,6 +521,68 @@ describe('share routes', () => { }); }); +describe('share fork route', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forks a shared conversation for the requesting user', async () => { + const forkResult = { + conversation: { conversationId: 'convo-456', title: 'Shared Title' }, + messages: [{ messageId: 'msg-456' }], + }; + forkSharedConversation.mockResolvedValue(forkResult); + + const response = await request( + buildApp({ user: { id: 'user-123', role: 'USER', tenantId: 'tenant-viewer' } }), + ) + .post('/api/share/share-123/fork') + .send({ targetMessageIndex: 3 }); + + expect(response.status).toBe(201); + expect(response.body).toEqual(forkResult); + expect(forkSharedConversation).toHaveBeenCalledWith({ + shareId: 'share-123', + shareResourceId: 'resource-123', + requestUserId: 'user-123', + userRole: 'USER', + userTenantId: 'tenant-viewer', + targetMessageIndex: 3, + snapshotFiles: true, + }); + }); + + it('forces snapshotFiles=false into the fork when the file snapshot kill switch is active', async () => { + isFileSnapshotKillSwitchActive.mockReturnValueOnce(true); + forkSharedConversation.mockResolvedValue({ + conversation: { conversationId: 'convo-456' }, + messages: [], + }); + + await request(buildApp()).post('/api/share/share-123/fork'); + + expect(forkSharedConversation).toHaveBeenCalledWith( + expect.objectContaining({ snapshotFiles: false }), + ); + }); + + it('returns 404 when the shared conversation is missing or empty', async () => { + forkSharedConversation.mockResolvedValue(null); + + const response = await request(buildApp()).post('/api/share/share-123/fork'); + + expect(response.status).toBe(404); + }); + + it('returns 500 when forking fails', async () => { + forkSharedConversation.mockRejectedValue(new Error('db down')); + + const response = await request(buildApp()).post('/api/share/share-123/fork'); + + expect(response.status).toBe(500); + }); +}); + describe('share-scoped file routes', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 1a15bd2f73..05af670bfb 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -37,6 +37,8 @@ const { const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { cleanFileName, getContentDisposition } = require('~/server/utils/files'); const canAccessSharedLink = require('~/server/middleware/canAccessSharedLink'); +const { forkSharedConversation } = require('~/server/utils/import/fork'); +const { createForkLimiters } = require('~/server/middleware/limiters'); const optionalShareFileAuth = require('~/server/middleware/optionalShareFileAuth'); const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); @@ -224,6 +226,8 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => { }; if (allowSharedLinks) { + const { forkIpLimiter, forkUserLimiter } = createForkLimiters(); + router.get('/:shareId/config', optionalJwtAuth, canAccessSharedLink, async (_req, res) => { try { const payload = await getShareStartupPayload(); @@ -260,6 +264,36 @@ if (allowSharedLinks) { }, ); + router.post( + '/:shareId/fork', + requireJwtAuth, + forkIpLimiter, + forkUserLimiter, + canAccessSharedLink, + async (req, res) => { + try { + const result = await forkSharedConversation({ + shareId: req.params.shareId, + shareResourceId: req.shareResourceId, + requestUserId: req.user.id, + userRole: req.user.role, + userTenantId: req.user.tenantId, + targetMessageIndex: req.body?.targetMessageIndex, + // Viewer-independent: honor the global shared-file kill switch, matching + // the GET share route so disabled file snapshots aren't copied into forks. + snapshotFiles: !isFileSnapshotKillSwitchActive(), + }); + if (!result) { + return res.status(404).json({ message: 'Shared conversation not found' }); + } + res.status(201).json(result); + } catch (error) { + logger.error('Error forking shared conversation:', error); + res.status(500).json({ message: 'Error forking shared conversation' }); + } + }, + ); + /** * Preview status for a snapshotted file. Read live from the file record so the * status is always current (deferred previews may resolve after the share was diff --git a/api/server/utils/import/defaults.js b/api/server/utils/import/defaults.js index 2212c09359..6d6f806d71 100644 --- a/api/server/utils/import/defaults.js +++ b/api/server/utils/import/defaults.js @@ -60,8 +60,83 @@ async function resolveImportDefaultModel({ endpoint, requestUserId, userRole }) return FALLBACK_MODEL_BY_ENDPOINT[endpoint] ?? openAISettings.model.default; } +/** + * Preferred endpoint order for conversations cloned without a known source + * endpoint. OpenAI is first so deployments that expose it keep prior behavior; + * any other configured endpoint is still selected when these are unavailable. + */ +const DEFAULT_ENDPOINT_PREFERENCE = [ + EModelEndpoint.openAI, + EModelEndpoint.anthropic, + EModelEndpoint.google, + EModelEndpoint.azureOpenAI, + EModelEndpoint.bedrock, +]; + +/** + * Endpoints excluded as fork targets because they are stateful: each + * conversation needs an assistant_id and thread_id that a cloned conversation + * never creates, so the assistants chat controller rejects the first follow-up + * ("Missing thread_id for existing conversation"). A fork must land on a + * stateless chat endpoint. These can still surface in the runtime models config + * (e.g. a deployment exposing only assistant models), so filter them out. + */ +const EXCLUDED_FORK_ENDPOINTS = new Set([ + EModelEndpoint.assistants, + EModelEndpoint.azureAssistants, +]); + +/** + * Resolves an endpoint and model the requesting user can actually use, for + * conversations cloned without a known source endpoint (shared forks, whose + * original endpoint is stripped from the sanitized payload). Picks the first + * preferred endpoint exposing models, then any other configured endpoint + * (excluding stateful assistant endpoints, which a fork cannot resume), so a + * deployment that doesn't expose OpenAI doesn't produce a conversation whose + * first message is rejected by model validation. Falls back to OpenAI defaults + * only when the runtime models config is empty or unavailable. + * + * @param {object} args + * @param {string} args.requestUserId - The id of the requesting user. + * @param {string} [args.userRole] - The role of the requesting user. + * @returns {Promise<{ endpoint: string, model: string }>} A usable endpoint and model. + */ +async function resolveImportDefaultEndpoint({ requestUserId, userRole }) { + try { + const modelsConfig = await getModelsConfig({ + user: { id: requestUserId, role: userRole, tenantId: getTenantId() }, + }); + if (modelsConfig) { + const orderedEndpoints = [ + ...DEFAULT_ENDPOINT_PREFERENCE, + ...Object.keys(modelsConfig).filter( + (endpoint) => !DEFAULT_ENDPOINT_PREFERENCE.includes(endpoint), + ), + ]; + for (const endpoint of orderedEndpoints) { + if (EXCLUDED_FORK_ENDPOINTS.has(endpoint)) { + continue; + } + const model = pickFirstConfiguredModel(endpoint, modelsConfig); + if (model) { + return { endpoint, model }; + } + } + } + } catch (error) { + logger.warn( + `[import] Failed to resolve a default endpoint from modelsConfig: ${error.message}`, + ); + } + return { + endpoint: EModelEndpoint.openAI, + model: FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.openAI] ?? openAISettings.model.default, + }; +} + module.exports = { FALLBACK_MODEL_BY_ENDPOINT, pickFirstConfiguredModel, resolveImportDefaultModel, + resolveImportDefaultEndpoint, }; diff --git a/api/server/utils/import/defaults.spec.js b/api/server/utils/import/defaults.spec.js index 46f233afb5..6815efb687 100644 --- a/api/server/utils/import/defaults.spec.js +++ b/api/server/utils/import/defaults.spec.js @@ -18,6 +18,7 @@ jest.mock('@librechat/data-schemas', () => { const { pickFirstConfiguredModel, resolveImportDefaultModel, + resolveImportDefaultEndpoint, FALLBACK_MODEL_BY_ENDPOINT, } = require('./defaults'); @@ -120,3 +121,85 @@ describe('resolveImportDefaultModel', () => { ); }); }); + +describe('resolveImportDefaultEndpoint', () => { + it('prefers OpenAI when it exposes models', async () => { + mockGetModelsConfig.mockResolvedValueOnce({ + [EModelEndpoint.openAI]: ['gpt-4o'], + [EModelEndpoint.anthropic]: ['claude-opus-4-7'], + }); + + const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' }); + + expect(result).toEqual({ endpoint: EModelEndpoint.openAI, model: 'gpt-4o' }); + }); + + it('falls back to another configured endpoint when OpenAI is unavailable', async () => { + mockGetModelsConfig.mockResolvedValueOnce({ + [EModelEndpoint.openAI]: [], + [EModelEndpoint.anthropic]: ['claude-opus-4-7'], + }); + + const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' }); + + expect(result).toEqual({ endpoint: EModelEndpoint.anthropic, model: 'claude-opus-4-7' }); + }); + + it('selects a custom endpoint when no preferred endpoint has models', async () => { + mockGetModelsConfig.mockResolvedValueOnce({ + 'my-custom': ['custom-model-1'], + }); + + const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' }); + + expect(result).toEqual({ endpoint: 'my-custom', model: 'custom-model-1' }); + }); + + it('skips stateful assistant endpoints and selects a stateless one', async () => { + mockGetModelsConfig.mockResolvedValueOnce({ + [EModelEndpoint.assistants]: ['gpt-4o'], + [EModelEndpoint.azureAssistants]: ['gpt-4o'], + 'my-custom': ['custom-model-1'], + }); + + const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' }); + + expect(result).toEqual({ endpoint: 'my-custom', model: 'custom-model-1' }); + }); + + it('falls back to OpenAI defaults when only assistant endpoints expose models', async () => { + mockGetModelsConfig.mockResolvedValueOnce({ + [EModelEndpoint.assistants]: ['gpt-4o'], + [EModelEndpoint.azureAssistants]: ['gpt-4o'], + }); + + const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' }); + + expect(result).toEqual({ + endpoint: EModelEndpoint.openAI, + model: openAISettings.model.default, + }); + }); + + it('falls back to OpenAI defaults when the models config is empty', async () => { + mockGetModelsConfig.mockResolvedValueOnce({}); + + const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' }); + + expect(result).toEqual({ + endpoint: EModelEndpoint.openAI, + model: openAISettings.model.default, + }); + }); + + it('falls back to OpenAI defaults when getModelsConfig rejects', async () => { + mockGetModelsConfig.mockRejectedValueOnce(new Error('boom')); + + const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' }); + + expect(result).toEqual({ + endpoint: EModelEndpoint.openAI, + model: openAISettings.model.default, + }); + }); +}); diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js index 5df4d27af2..b0ba7c0210 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -1,9 +1,11 @@ const { v4: uuidv4 } = require('uuid'); -const { logger } = require('@librechat/data-schemas'); +const { logger, tenantStorage } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider'); +const { getConvo, getMessages, getSharedMessages } = require('~/models'); const { createImportBatchBuilder } = require('./importBatchBuilder'); +const { getAppConfig } = require('~/server/services/Config'); +const { resolveImportDefaultEndpoint } = require('./defaults'); const BaseClient = require('~/app/clients/BaseClient'); -const { getConvo, getMessages } = require('~/models'); /** * Helper function to clone messages with proper parent-child relationships and timestamps @@ -352,6 +354,146 @@ function splitAtTargetLevel(messages, targetMessageId) { return filteredMessages; } +/** + * Strips file identifiers from a shared message's `files` and `attachments`. + * A shared fork is owned by the requesting user, but the underlying file records + * still belong to the original sharer. Persisting their `file_id`s would let the + * agents file-resend path collect them on the next turn and call `getUserCodeFiles`, + * which looks them up by `file_id` with no ownership filter, rehydrating the + * sharer's files into the viewer's run. Dropping the ids keeps a fork's file + * access no broader than viewing the read-only share, while leaving render-only + * metadata (e.g. `filepath`, `toolCallId`) intact. + * @param {TMessage} message - The shared message to sanitize. + * @returns {TMessage} The message with file identifiers removed. + */ +function stripSharedFileIds(message) { + const sanitized = { ...message }; + if (Array.isArray(sanitized.files)) { + sanitized.files = sanitized.files.map(({ file_id: _fileId, ...file }) => file); + } + if (Array.isArray(sanitized.attachments)) { + sanitized.attachments = sanitized.attachments.map( + ({ file_id: _fileId, ...attachment }) => attachment, + ); + } + return sanitized; +} + +/** + * Forks a shared (sanitized) conversation into a fresh conversation owned by the requesting user. + * Only the anonymized, allowlisted message fields returned by `getSharedMessages` are cloned, + * so no private data from the original owner can leak into the new conversation. + * @param {object} params - The parameters for forking the shared conversation. + * @param {string} params.shareId - The ID of the shared link to fork from. + * @param {string} [params.shareResourceId] - The SharedLink resource ID set by `canAccessSharedLink`. + * @param {string} params.requestUserId - The ID of the user making the request. + * @param {string} [params.userRole] - The role of the requesting user, used to resolve the default model. + * @param {string} [params.userTenantId] - Tenant of the requesting user. `canAccessSharedLink` runs this handler under the share owner's tenant so the share resolves, so the copy must be persisted (and its config/retention resolved) under the requesting user's tenant or it would be invisible (404) when they open it normally. + * @param {number} [params.targetMessageIndex] - Index, within the shared payload, of the message at the tip of the branch the viewer has active. When set, only the direct path to that message is cloned so the fork continues the branch that was actually shown rather than the newest sibling. An index is used (not id or `createdAt`) because shared ids are re-anonymized per request while `getSharedMessages` returns a deterministic, stable order, so the same index resolves to the same message on the server. + * @param {boolean} [params.snapshotFiles] - When `false`, file/attachment metadata is omitted from the cloned messages, mirroring the GET share route so the global shared-file kill switch is honored. + * @param {(userId: string, interfaceConfig?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. + * @param {(options: object) => Promise} [params.loadAppConfig] - Resolves the app config; injectable for tests. Called inside the requesting user's tenant context so retention policy is read from the viewer's tenant, not the share owner's. + * @returns {Promise} The new conversation and messages, or null when the share is missing or empty. + */ +async function forkSharedConversation({ + shareId, + shareResourceId, + requestUserId, + userRole, + userTenantId, + targetMessageIndex, + snapshotFiles, + builderFactory = createImportBatchBuilder, + loadAppConfig = getAppConfig, +}) { + // Mirror the GET share route: when the shared-file snapshot is globally + // disabled, omit file/attachment metadata so a fork can't persist filenames + // or share file URLs into the new conversation while file serving is off. + const share = await getSharedMessages(shareId, shareResourceId, { snapshotFiles }); + if (!share?.messages?.length) { + return null; + } + + /** + * The shared payload includes sibling branches. Reduce to the direct path of + * the viewer's active message so the fork continues exactly the branch that + * was shown; without this the default branch selection lands on the newest + * sibling. The active tip is located by its index in the shared payload, which + * `getSharedMessages` returns in a deterministic order (stored ref-array order) + * — unlike ids (re-anonymized per request) or `createdAt` (can collide). Falls + * back to the full set when the index is absent or out of range. + */ + let sourceMessages = share.messages; + if ( + Number.isInteger(targetMessageIndex) && + targetMessageIndex >= 0 && + targetMessageIndex < share.messages.length + ) { + const targetMessage = share.messages[targetMessageIndex]; + const directPath = BaseClient.getMessagesForConversation({ + messages: share.messages, + parentMessageId: targetMessage.messageId, + }); + if (directPath.length > 0) { + sourceMessages = directPath; + } + } + + const messageIds = new Set(sourceMessages.map((message) => message.messageId)); + const messagesToClone = sourceMessages.map(({ model: _model, ...message }) => + stripSharedFileIds({ + ...message, + parentMessageId: + message.parentMessageId != null && messageIds.has(message.parentMessageId) + ? message.parentMessageId + : Constants.NO_PARENT, + }), + ); + + /** + * Persist and read back under the requesting user's tenant rather than the + * share owner's. The read above runs in the share owner's tenant (set by + * `canAccessSharedLink`); writing the copy there would leave it invisible to + * the user under their normal tenant context (the new conversation would 404 + * when they navigate to it). Switching to the user's tenant only affects this + * deployment when tenant isolation is enabled; otherwise it is a no-op. + */ + return tenantStorage.run({ tenantId: userTenantId, userId: requestUserId }, async () => { + // Resolve config inside the viewer's tenant so retention (e.g. all-data + // expiry) reflects the requesting user's tenant, not the share owner's. + const appConfig = await loadAppConfig({ + role: userRole, + userId: requestUserId, + tenantId: userTenantId, + }); + // The shared payload strips the original endpoint, so resolve one the viewer + // can actually use; hard-coding OpenAI breaks the first follow-up message on + // deployments that don't expose it. + const { endpoint, model } = await resolveImportDefaultEndpoint({ requestUserId, userRole }); + const importBatchBuilder = builderFactory(requestUserId, appConfig?.interfaceConfig); + importBatchBuilder.startConversation(endpoint); + + cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder); + + const result = importBatchBuilder.finishConversation(share.title, new Date(), {}, model); + await importBatchBuilder.saveBatch(); + logger.debug( + `user: ${requestUserId} | New conversation "${result.conversation.title}" forked from share ID ${shareId}`, + ); + + const conversation = await getConvo(requestUserId, result.conversation.conversationId); + const messages = await getMessages({ + user: requestUserId, + conversationId: conversation.conversationId, + }); + + return { + conversation, + messages, + }; + }); +} + /** * Duplicates a conversation and all its messages. * @param {object} params - The parameters for duplicating the conversation. @@ -404,6 +546,7 @@ module.exports = { forkConversation, splitAtTargetLevel, duplicateConversation, + forkSharedConversation, getAllMessagesUpToParent, getMessagesUpToTargetLevel, cloneMessagesWithTimestamps, diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js index 6fd108674a..adb576d0cb 100644 --- a/api/server/utils/import/fork.spec.js +++ b/api/server/utils/import/fork.spec.js @@ -6,6 +6,15 @@ jest.mock('~/models', () => ({ getMessages: jest.fn(), bulkSaveMessages: jest.fn(), bulkIncrementTagCounts: jest.fn(), + getSharedMessages: jest.fn(), +})); + +jest.mock('~/server/controllers/ModelController', () => ({ + getModelsConfig: jest.fn().mockResolvedValue({ openAI: ['gpt-test'] }), +})); + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn().mockResolvedValue({ interfaceConfig: {} }), })); let mockIdCounter = 0; @@ -21,6 +30,7 @@ jest.mock('uuid', () => { const { forkConversation, duplicateConversation, + forkSharedConversation, splitAtTargetLevel, getAllMessagesUpToParent, getMessagesUpToTargetLevel, @@ -32,7 +42,9 @@ const { bulkSaveConvos, getMessages, bulkSaveMessages, + getSharedMessages, } = require('~/models'); +const { getModelsConfig } = require('~/server/controllers/ModelController'); const { createImportBatchBuilder } = require('./importBatchBuilder'); const BaseClient = require('~/app/clients/BaseClient'); @@ -301,6 +313,359 @@ describe('duplicateConversation', () => { }); }); +describe('forkSharedConversation', () => { + const mockSharedMessages = [ + { + messageId: 'msg_a', + parentMessageId: Constants.NO_PARENT, + text: 'Shared root', + isCreatedByUser: true, + createdAt: '2021-01-01', + }, + { + messageId: 'msg_b', + parentMessageId: 'msg_a', + text: 'Shared reply', + isCreatedByUser: false, + createdAt: '2021-01-02', + }, + ]; + + const mockShare = { + shareId: 'share123', + conversationId: 'convo_anon', + title: 'Shared Title', + messages: mockSharedMessages, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockIdCounter = 0; + getSharedMessages.mockResolvedValue(mockShare); + getConvo.mockResolvedValue(mockConversation); + getMessages.mockResolvedValue(mockSharedMessages); + bulkSaveConvos.mockResolvedValue(null); + bulkSaveMessages.mockResolvedValue(null); + bulkIncrementTagCounts.mockResolvedValue(null); + }); + + test('should clone shared messages into a conversation owned by the requesting user', async () => { + const result = await forkSharedConversation({ + shareId: 'share123', + shareResourceId: 'resource123', + requestUserId: 'user1', + }); + + expect(getSharedMessages).toHaveBeenCalledWith('share123', 'resource123', { + snapshotFiles: undefined, + }); + + const savedMessages = bulkSaveMessages.mock.calls[0][0]; + expect(savedMessages).toHaveLength(2); + const [root, reply] = savedMessages; + expect(root).toMatchObject({ + text: 'Shared root', + user: 'user1', + endpoint: 'openAI', + parentMessageId: Constants.NO_PARENT, + }); + expect(reply).toMatchObject({ + text: 'Shared reply', + user: 'user1', + parentMessageId: root.messageId, + }); + expect(root.messageId).not.toBe('msg_a'); + expect(reply.messageId).not.toBe('msg_b'); + + const savedConvos = bulkSaveConvos.mock.calls[0][0]; + expect(savedConvos[0]).toMatchObject({ + user: 'user1', + title: 'Shared Title', + endpoint: 'openAI', + model: 'gpt-test', + }); + + expect(getConvo).toHaveBeenCalledWith('user1', savedConvos[0].conversationId); + expect(result).toMatchObject({ conversation: mockConversation, messages: mockSharedMessages }); + }); + + test('should use an available endpoint when the deployment does not expose OpenAI', async () => { + getModelsConfig.mockResolvedValueOnce({ anthropic: ['claude-test'] }); + + await forkSharedConversation({ + shareId: 'share123', + shareResourceId: 'resource123', + requestUserId: 'user1', + }); + + const savedConvos = bulkSaveConvos.mock.calls[0][0]; + expect(savedConvos[0]).toMatchObject({ endpoint: 'anthropic', model: 'claude-test' }); + + const savedMessages = bulkSaveMessages.mock.calls[0][0]; + expect(savedMessages.every((message) => message.endpoint === 'anthropic')).toBe(true); + }); + + test('should return null when the share is not found', async () => { + getSharedMessages.mockResolvedValue(null); + + const result = await forkSharedConversation({ + shareId: 'missing', + requestUserId: 'user1', + }); + + expect(result).toBeNull(); + expect(bulkSaveMessages).not.toHaveBeenCalled(); + }); + + test('should return null when the share has no messages', async () => { + getSharedMessages.mockResolvedValue({ ...mockShare, messages: [] }); + + const result = await forkSharedConversation({ + shareId: 'share123', + requestUserId: 'user1', + }); + + expect(result).toBeNull(); + expect(bulkSaveMessages).not.toHaveBeenCalled(); + }); + + test('should normalize orphaned parentMessageId references to NO_PARENT', async () => { + getSharedMessages.mockResolvedValue({ + ...mockShare, + messages: [ + { + messageId: 'msg_orphan', + parentMessageId: 'msg_deleted', + text: 'Orphaned message', + createdAt: '2021-01-01', + }, + ], + }); + + await forkSharedConversation({ + shareId: 'share123', + requestUserId: 'user1', + }); + + const savedMessages = bulkSaveMessages.mock.calls[0][0]; + expect(savedMessages[0].parentMessageId).toBe(Constants.NO_PARENT); + }); + + test('should forward snapshotFiles to getSharedMessages so the kill switch is honored', async () => { + await forkSharedConversation({ + shareId: 'share123', + shareResourceId: 'resource123', + requestUserId: 'user1', + snapshotFiles: false, + }); + + expect(getSharedMessages).toHaveBeenCalledWith('share123', 'resource123', { + snapshotFiles: false, + }); + }); + + test('should strip anonymized model identifiers from cloned messages', async () => { + getSharedMessages.mockResolvedValue({ + ...mockShare, + messages: [ + { + messageId: 'msg_a', + parentMessageId: Constants.NO_PARENT, + text: 'Assistant message', + model: 'a_anon123', + createdAt: '2021-01-01', + }, + ], + }); + + await forkSharedConversation({ + shareId: 'share123', + requestUserId: 'user1', + }); + + const savedMessages = bulkSaveMessages.mock.calls[0][0]; + expect(savedMessages[0].model).not.toBe('a_anon123'); + }); + + test('should strip file_id from cloned files and attachments', async () => { + getSharedMessages.mockResolvedValue({ + ...mockShare, + messages: [ + { + messageId: 'msg_a', + parentMessageId: Constants.NO_PARENT, + text: 'Message with files', + isCreatedByUser: true, + createdAt: '2021-01-01', + files: [{ file_id: 'owner-file-1', filepath: '/images/owner/a.png' }], + attachments: [ + { file_id: 'owner-file-2', toolCallId: 'tool_1', filepath: '/images/owner/b.png' }, + ], + }, + ], + }); + + await forkSharedConversation({ + shareId: 'share123', + requestUserId: 'user1', + }); + + const savedMessages = bulkSaveMessages.mock.calls[0][0]; + const [message] = savedMessages; + expect(message.files[0]).not.toHaveProperty('file_id'); + expect(message.attachments[0]).not.toHaveProperty('file_id'); + // Render-only metadata is preserved + expect(message.files[0].filepath).toBe('/images/owner/a.png'); + expect(message.attachments[0].toolCallId).toBe('tool_1'); + }); + + test('should resolve interfaceConfig from the app config and pass it to the builder', async () => { + const interfaceConfig = { retentionMode: 'all', retention: { days: 30 } }; + const loadAppConfig = jest.fn().mockResolvedValue({ interfaceConfig }); + const builderFactory = jest.fn((userId, config) => createImportBatchBuilder(userId, config)); + + await forkSharedConversation({ + shareId: 'share123', + requestUserId: 'user1', + userRole: 'USER', + userTenantId: 'tenant-viewer', + loadAppConfig, + builderFactory, + }); + + expect(loadAppConfig).toHaveBeenCalledWith({ + role: 'USER', + userId: 'user1', + tenantId: 'tenant-viewer', + }); + expect(builderFactory).toHaveBeenCalledWith('user1', interfaceConfig); + }); + + test('should resolve the app config under the requesting user tenant', async () => { + const { tenantStorage, getTenantId } = require('@librechat/data-schemas'); + let tenantDuringConfigLoad; + const loadAppConfig = jest.fn(async () => { + tenantDuringConfigLoad = getTenantId(); + return { interfaceConfig: {} }; + }); + + await tenantStorage.run({ tenantId: 'tenant-share-owner' }, () => + forkSharedConversation({ + shareId: 'share123', + requestUserId: 'user1', + userTenantId: 'tenant-viewer', + loadAppConfig, + }), + ); + + expect(tenantDuringConfigLoad).toBe('tenant-viewer'); + }); + + test('should clone only the active branch path when targetMessageIndex is provided', async () => { + getSharedMessages.mockResolvedValue({ + ...mockShare, + messages: [ + { + messageId: 'msg_root', + parentMessageId: Constants.NO_PARENT, + text: 'Root', + createdAt: '2021-01-01T00:00:00.000Z', + }, + { + messageId: 'msg_branch_a', + parentMessageId: 'msg_root', + text: 'Branch A (shared)', + createdAt: '2021-01-02T00:00:00.000Z', + }, + { + messageId: 'msg_branch_b', + parentMessageId: 'msg_root', + text: 'Branch B (newer sibling)', + createdAt: '2021-01-03T00:00:00.000Z', + }, + ], + }); + + // Index 1 = the "Branch A" tip the viewer had active. + await forkSharedConversation({ + shareId: 'share123', + requestUserId: 'user1', + targetMessageIndex: 1, + }); + + const savedTexts = bulkSaveMessages.mock.calls[0][0].map((message) => message.text); + expect(savedTexts).toEqual(['Root', 'Branch A (shared)']); + expect(savedTexts).not.toContain('Branch B (newer sibling)'); + }); + + test('should select the correct branch even when siblings share a createdAt', async () => { + getSharedMessages.mockResolvedValue({ + ...mockShare, + messages: [ + { + messageId: 'msg_root', + parentMessageId: Constants.NO_PARENT, + text: 'Root', + createdAt: '2021-01-01T00:00:00.000Z', + }, + { + messageId: 'msg_sib_a', + parentMessageId: 'msg_root', + text: 'Sibling A', + createdAt: '2021-01-02T00:00:00.000Z', + }, + { + messageId: 'msg_sib_b', + parentMessageId: 'msg_root', + text: 'Sibling B (same timestamp)', + createdAt: '2021-01-02T00:00:00.000Z', + }, + ], + }); + + // Index 2 unambiguously targets Sibling B despite the shared createdAt. + await forkSharedConversation({ + shareId: 'share123', + requestUserId: 'user1', + targetMessageIndex: 2, + }); + + const savedTexts = bulkSaveMessages.mock.calls[0][0].map((message) => message.text); + expect(savedTexts).toEqual(['Root', 'Sibling B (same timestamp)']); + expect(savedTexts).not.toContain('Sibling A'); + }); + + test('should fall back to the full set when targetMessageIndex is out of range', async () => { + await forkSharedConversation({ + shareId: 'share123', + requestUserId: 'user1', + targetMessageIndex: 999, + }); + + expect(bulkSaveMessages.mock.calls[0][0]).toHaveLength(mockSharedMessages.length); + }); + + test('should persist under the requesting user tenant, not the share tenant', async () => { + const { tenantStorage, getTenantId } = require('@librechat/data-schemas'); + let tenantDuringSave; + bulkSaveConvos.mockImplementation(async () => { + tenantDuringSave = getTenantId(); + }); + + // Simulate the handler running inside the share owner's tenant context + // (as `canAccessSharedLink` does) and ensure the write switches to the viewer's. + await tenantStorage.run({ tenantId: 'tenant-share-owner' }, () => + forkSharedConversation({ + shareId: 'share123', + requestUserId: 'user1', + userTenantId: 'tenant-viewer', + }), + ); + + expect(tenantDuringSave).toBe('tenant-viewer'); + }); +}); + const mockMessagesComplex = [ { messageId: '7', parentMessageId: Constants.NO_PARENT, text: 'Message 7' }, { messageId: '8', parentMessageId: Constants.NO_PARENT, text: 'Message 8' }, diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx index 39d42462bc..fe4b87262a 100644 --- a/client/src/components/Chat/TemporaryChat.tsx +++ b/client/src/components/Chat/TemporaryChat.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useRecoilValue } from 'recoil'; import { TooltipAnchor } from '@librechat/client'; import { MessageCircleDashed } from 'lucide-react'; +import { Constants } from 'librechat-data-provider'; import { useRecoilState, useRecoilCallback } from 'recoil'; import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; import { useLocalize } from '~/hooks'; @@ -23,7 +24,11 @@ export function TemporaryChat() { [isTemporary], ); + const conversationId = conversation?.conversationId; + const hasStarted = conversationId != null && conversationId !== Constants.NEW_CONVO; + if ( + hasStarted || (Array.isArray(conversation?.messages) && conversation.messages.length >= 1) || isSubmitting ) { diff --git a/client/src/components/Nav/SettingsTabs/General/Selectors.tsx b/client/src/components/Nav/SettingsTabs/General/Selectors.tsx index 0470fd94ce..25f9d066c7 100644 --- a/client/src/components/Nav/SettingsTabs/General/Selectors.tsx +++ b/client/src/components/Nav/SettingsTabs/General/Selectors.tsx @@ -1,16 +1,19 @@ import { useRecoilValue } from 'recoil'; import { Dropdown, Spinner } from '@librechat/client'; import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; import store from '~/store'; export const ThemeSelector = ({ theme, onChange, portal = true, + popoverClassName, }: { theme: string; onChange: (value: string) => void; portal?: boolean; + popoverClassName?: string; }) => { const localize = useLocalize(); @@ -30,9 +33,8 @@ export const ThemeSelector = ({ value={theme} onChange={onChange} options={themeOptions} - sizeClasses="w-[180px]" + sizeClasses={cn('z-50 w-[180px]', popoverClassName)} testId="theme-selector" - className="z-50" aria-labelledby={labelId} portal={portal} /> @@ -44,10 +46,12 @@ export const LangSelector = ({ langcode, onChange, portal = true, + popoverClassName, }: { langcode: string; onChange: (value: string) => void; portal?: boolean; + popoverClassName?: string; }) => { const localize = useLocalize(); const isLanguageLoading = useRecoilValue(store.languageLoading); @@ -116,9 +120,11 @@ export const LangSelector = ({ { + navigate(`/c/${forkData.conversation.conversationId}`); + }, + onError: (error) => { + const status = getResponseStatus(error); + /** A 401 means the viewer isn't authenticated; the request interceptor + * routes them through login (with a redirect back to this share), so a + * generic error toast would be misleading noise before the redirect. */ + if (status === 401) { + return; + } + showToast({ + message: + status === 429 + ? localize('com_ui_fork_error_rate_limit') + : localize('com_ui_continue_chat_error'), + status: 'error', + }); + }, + }); + + /** Resolve the index, within the shared payload, of the message at the tip of + * the branch the viewer currently has active (default or manually navigated), + * so the fork continues that exact branch instead of the newest sibling. + * Mirrors the share tree's sibling selection, which is keyed by parent id with + * the root on SHARED_CONVO_KEY. An index is sent (not id or createdAt) because + * shared ids are re-anonymized per request and createdAt can collide, while + * the payload order is stable across requests. */ + const getActiveTargetIndex = useRecoilCallback( + ({ snapshot }) => + () => { + const messages = data?.messages; + if (messages == null || messages.length === 0) { + return undefined; + } + const getSiblingIndex = (parentMessageId: string | null | undefined) => + snapshot + .getLoadable(store.messagesSiblingIdxFamily(parentMessageId ?? SHARED_CONVO_KEY)) + .getValue() ?? 0; + const tail = selectActiveBranchTail(messages, SHARED_CONVO_KEY, getSiblingIndex); + if (tail == null) { + return undefined; + } + const index = messages.findIndex((message) => message.messageId === tail.messageId); + return index >= 0 ? index : undefined; + }, + [data?.messages], + ); + + const { mutate: forkSharedConvo } = forkShare; + const handleContinue = useCallback(() => { + if (shareId == null || shareId === '') { + return; + } + forkSharedConvo({ shareId, targetMessageIndex: getActiveTargetIndex() }); + }, [shareId, forkSharedConvo, getActiveTargetIndex]); + // configure document title let docTitle = ''; if (config?.appTitle != null && data?.title != null) { @@ -104,9 +169,12 @@ function SharedView() { onThemeChange={handleThemeChange} onLangChange={handleLangChange} settingsLabel={localize('com_nav_settings')} + continueLabel={localize('com_ui_continue_chat')} + onContinue={handleContinue} + isContinuing={forkShare.isLoading} /> - + ); @@ -164,6 +232,9 @@ interface ShareHeaderProps { theme: string; langcode: string; settingsLabel: string; + continueLabel: string; + isContinuing: boolean; + onContinue: () => void; onThemeChange: (value: string) => void; onLangChange: (value: string) => void; } @@ -174,6 +245,9 @@ function ShareHeader({ theme, langcode, settingsLabel, + continueLabel, + isContinuing, + onContinue, onThemeChange, onLangChange, }: ShareHeaderProps) { @@ -182,7 +256,7 @@ function ShareHeader({ const handleDialogOutside = useCallback((event: Event) => { const target = event.target as HTMLElement | null; - if (target?.closest('[data-dialog-ignore="true"]')) { + if (target?.closest('[data-dialog-ignore="true"], .popover-ui')) { event.preventDefault(); } }, []); @@ -203,44 +277,64 @@ function ShareHeader({ )} - - - - - + + + + + + + + {settingsLabel} + +
+ +
+
-
-
- -
-
- - + + +
diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index c919e0eaf5..9cecb49517 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -695,6 +695,41 @@ export const useForkConvoMutation = ( }); }; +export const useForkSharedConvoMutation = ( + options?: t.ForkSharedConvoOptions, +): UseMutationResult => { + const queryClient = useQueryClient(); + const { onSuccess, ..._options } = options ?? {}; + + return useMutation( + (payload: t.TForkSharedConvoRequest) => + dataService.forkSharedConversation(payload.shareId, payload.targetMessageIndex), + { + onSuccess: (data, vars, context) => { + const forkedConversation = data.conversation; + const forkedConversationId = forkedConversation?.conversationId; + if (!forkedConversationId) { + return; + } + + queryClient.setQueryData( + [QueryKeys.conversation, forkedConversationId], + forkedConversation, + ); + addConvoToAllQueries(queryClient, forkedConversation); + queryClient.setQueryData([QueryKeys.messages, forkedConversationId], data.messages); + queryClient.invalidateQueries({ + queryKey: [QueryKeys.allConversations], + refetchPage: (_, index) => index === 0, + }); + + onSuccess?.(data, vars, context); + }, + ..._options, + }, + ); +}; + export const useUploadConversationsMutation = ( _options?: t.MutationOptions, ) => { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 81df389135..0e436d77f5 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -966,6 +966,8 @@ "com_ui_context_usage_snapshot_unknown": "Context {{0}}", "com_ui_context_window": "Context window", "com_ui_continue": "Continue", + "com_ui_continue_chat": "Continue this chat", + "com_ui_continue_chat_error": "There was an error copying this conversation", "com_ui_continue_oauth": "Continue with OAuth", "com_ui_control_bar": "Control bar", "com_ui_conversation": "conversation", diff --git a/packages/api/src/shared-links/access.test.ts b/packages/api/src/shared-links/access.test.ts index 3f8ea77146..f76284227e 100644 --- a/packages/api/src/shared-links/access.test.ts +++ b/packages/api/src/shared-links/access.test.ts @@ -5,7 +5,7 @@ jest.mock('@librechat/data-schemas', () => ({ import mongoose, { Types, Model } from 'mongoose'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import { createModels, createMethods } from '@librechat/data-schemas'; +import { createModels, createMethods, tenantStorage } from '@librechat/data-schemas'; import { ResourceType, PrincipalType, AccessRoleIds } from 'librechat-data-provider'; import type { Request, Response, NextFunction } from 'express'; import type { IAclEntry, ISharedLink } from '@librechat/data-schemas'; @@ -240,6 +240,36 @@ describe('canAccessSharedLink', () => { }); }); + describe('cross-tenant lookup', () => { + test('resolves a share owned by another tenant via system fallback, then enforces the ACL', async () => { + // Share created under tenant-a; an authenticated viewer from tenant-b would + // miss the tenant-scoped lookup and previously get a 404 before access could + // be evaluated. The system fallback now resolves the share, and the ACL check + // (run under the share's own tenant) denies the viewer who has no grant. A 403 + // rather than a 404 confirms the share was found cross-tenant but not authorized + // — the fallback broadens the lookup, never the authorization. + const link = await tenantStorage.run({ tenantId: 'tenant-a' }, () => createTestLink()); + const viewer = new Types.ObjectId(); + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: viewer }, + ]); + + const req = createReq({ + params: { shareId: link.shareId }, + user: { id: viewer.toString(), _id: viewer, role: 'USER' }, + }); + const res = createRes(); + const next = jest.fn(); + await tenantStorage.run({ tenantId: 'tenant-b' }, () => + canAccessSharedLink(req, res, next as unknown as NextFunction), + ); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + expect(res._status).not.toBe(404); + }); + }); + describe('legacy link auto-migration', () => { async function createLegacyLink(isPublic: boolean) { const link = await createTestLink(); diff --git a/packages/api/src/shared-links/access.ts b/packages/api/src/shared-links/access.ts index 3cbb971c3a..5e1d23a32f 100644 --- a/packages/api/src/shared-links/access.ts +++ b/packages/api/src/shared-links/access.ts @@ -62,7 +62,16 @@ export function createSharedLinkAccessMiddleware(deps: SharedLinkAccessDeps) { shareId, ...activeExpirationFilter(), }).lean()) as RawSharedLink | null; - const rawShare = getTenantId() ? await findShare() : await runAsSystem(findShare); + // Resolve by the (globally unique, secret) shareId under the viewer's tenant + // first, then fall back to a system-wide lookup so a share owned by another + // tenant — e.g. a public link opened by an authenticated user from a + // different tenant — still resolves. Access remains gated by the ACL check + // below, which runs under the share's own tenant, so this only broadens the + // lookup, never the authorization. + let rawShare = getTenantId() ? await findShare() : await runAsSystem(findShare); + if (!rawShare && getTenantId()) { + rawShare = await runAsSystem(findShare); + } if (!rawShare) { res.status(404).json({ message: 'Shared link not found' }); diff --git a/packages/data-provider/specs/request-interceptor.spec.ts b/packages/data-provider/specs/request-interceptor.spec.ts index fec6a45cec..ae4a58d484 100644 --- a/packages/data-provider/specs/request-interceptor.spec.ts +++ b/packages/data-provider/specs/request-interceptor.spec.ts @@ -187,6 +187,46 @@ describe('axios 401 interceptor — Authorization header guard', () => { expect(refreshCall[0].url).toContain('api/auth/refresh'); }); + it('attempts refresh for the share fork POST even without Authorization header', async () => { + expect.assertions(2); + setTokenHeader(undefined); + + setWindowLocation({ + href: 'http://localhost/share/abc123', + pathname: '/share/abc123', + search: '', + hash: '', + } as Partial); + + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/api/share/abc123/fork', method: 'post', headers: {} }, + }); + + mockAdapter.mockResolvedValueOnce({ + data: { token: 'new-token' }, + status: 200, + headers: {}, + config: {}, + }); + + mockAdapter.mockResolvedValueOnce({ + data: { conversation: {}, messages: [] }, + status: 201, + headers: {}, + config: {}, + }); + + try { + await axios.post('/api/share/abc123/fork'); + } catch { + // may reject depending on exact flow + } + + expect(mockAdapter.mock.calls.length).toBe(3); + expect(mockAdapter.mock.calls[1][0].url).toContain('api/auth/refresh'); + }); + it('does not refresh or redirect for unrelated 401s on public shared link pages', async () => { expect.assertions(2); setTokenHeader(undefined); @@ -320,6 +360,36 @@ describe('axios 401 interceptor — Authorization header guard', () => { expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123'); }); + it('redirects to login when the share fork refresh itself fails (stale session)', async () => { + expect.assertions(1); + setTokenHeader(undefined); + + setWindowLocation({ + href: 'http://localhost/share/abc123', + pathname: '/share/abc123', + search: '', + hash: '', + } as Partial); + + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/api/share/abc123/fork', method: 'post', headers: {} }, + }); + + mockAdapter.mockRejectedValueOnce({ + response: { status: 403 }, + config: { url: '/api/auth/refresh', method: 'post', headers: {} }, + }); + + try { + await axios.post('/api/share/abc123/fork'); + } catch { + // expected rejection + } + + expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123'); + }); + it('redirects to login with redirect_to when authenticated and refresh returns no token on share page', async () => { expect.assertions(1); setTokenHeader('some-token'); diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 326e60f644..5661fc5166 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -71,6 +71,7 @@ export const messagesBranch = () => `${messagesRoot}/branch`; const shareRoot = `${BASE_URL}/api/share`; export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`; +export const forkSharedMessages = (shareId: string) => `${shareRoot}/${shareId}/fork`; export const sharedStartupConfig = (shareId: string) => `${shareMessages(shareId)}/config`; export const getSharedLink = (conversationId: string) => `${shareRoot}/link/${conversationId}`; export const getSharedLinks = ( diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 80e5555170..1a1eea87f8 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -799,6 +799,13 @@ export function forkConversation(payload: t.TForkConvoRequest): Promise { + return request.post(endpoints.forkSharedMessages(shareId), { targetMessageIndex }); +} + export function deleteConversation(payload: t.TDeleteConversationRequest) { return request.deleteWithOptions(endpoints.deleteConversation(), { data: { arg: payload } }); } diff --git a/packages/data-provider/src/request.ts b/packages/data-provider/src/request.ts index ca2a98eedf..528fd3d562 100644 --- a/packages/data-provider/src/request.ts +++ b/packages/data-provider/src/request.ts @@ -83,6 +83,7 @@ const refreshToken = (retry?: boolean): Promise pathname.startsWith('/') ? pathname : `/${pathname}`; @@ -121,6 +122,14 @@ const isSharedMessagesRequest = (url?: string, method?: string) => method?.toLowerCase() === 'get' && SHARED_MESSAGES_PATH_REGEX.test(stripBasePath(getRequestPathname(url))); +/** The "continue this chat" fork is a deliberate authenticated action initiated + * from a share page, so it must reach auth recovery/redirect like the shared + * data request — otherwise a logged-out (or cold-loaded) viewer's 401 is + * rejected silently instead of routing them through login. */ +const isShareForkRequest = (url?: string, method?: string) => + method?.toLowerCase() === 'post' && + SHARE_FORK_PATH_REGEX.test(stripBasePath(getRequestPathname(url))); + const dispatchTokenUpdatedEvent = (token: string) => { setTokenHeader(token); clearAuthRedirectStartedAt(); @@ -318,7 +327,11 @@ if (typeof window !== 'undefined') { * recover auth/redirect without unrelated share-page queries forcing login. */ if ( !axios.defaults.headers.common['Authorization'] && - !(isSharePage() && isSharedMessagesRequest(originalRequest.url, originalRequest.method)) + !( + isSharePage() && + (isSharedMessagesRequest(originalRequest.url, originalRequest.method) || + isShareForkRequest(originalRequest.url, originalRequest.method)) + ) ) { return Promise.reject(error); } @@ -347,8 +360,12 @@ if (typeof window !== 'undefined') { redirectToLoginOnce(); return Promise.reject(error); - } catch (err) { - return Promise.reject(err); + } catch { + /** A rejected refresh (stale/invalid session → 401/403) must route to + * login just like an empty-token refresh, otherwise the original 401 + * surfaces to the caller (e.g. the share fork button) with no redirect. */ + redirectToLoginOnce(); + return Promise.reject(error); } } diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 013d9f2943..a6ee6b2575 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -422,6 +422,14 @@ export type TForkConvoResponse = { messages: TMessage[]; }; +export type TForkSharedConvoRequest = { + shareId: string; + /** Index of the viewer's active message within the shared payload; reduces the + * fork to that branch. An index is used because shared ids are re-anonymized + * per request and `createdAt` can collide, while the payload order is stable. */ + targetMessageIndex?: number; +}; + export type TSearchResults = { conversations: TConversation[]; messages: TMessage[]; diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index d4fed908ff..654c078939 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -207,6 +207,11 @@ export type DuplicateConvoOptions = MutationOptions< export type ForkConvoOptions = MutationOptions; +export type ForkSharedConvoOptions = MutationOptions< + types.TForkConvoResponse, + types.TForkSharedConvoRequest +>; + export type CreateSharedLinkOptions = MutationOptions< types.TSharedLink, Partial