🪢 feat: show Langfuse session link in shared chats (#15273)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* feat: show Langfuse sessions in shared chats

* fix: wait for optional auth before loading shares

* fix: require Langfuse access for shared session links

* refactor: move shared Langfuse policy into API package

* fix: wrap shared chat actions on mobile

* fix: coordinate shared Langfuse link loading

* perf: batch shared Langfuse capability checks
This commit is contained in:
Ravi Kumar L 2026-08-27 20:29:57 +02:00 • committed by GitHub
parent 03fa98eb37
commit a70bcf66a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 614 additions and 59 deletions

View file

@ -3,26 +3,28 @@ const {
getUserPrincipals,
hasAnyConfigReadAccess,
hasCapabilityForPrincipals,
getHeldCapabilities,
getHeldCapabilities: getHeldCapabilitiesForPrincipals,
} = require('~/models');
const {
hasCapability,
requireCapability,
hasConfigCapability,
getHeldCapabilities,
hasAnyConfigReadAccess: checkAnyConfigReadAccess,
getReadableConfigSections,
} = generateCapabilityCheck({
getUserPrincipals,
hasAnyConfigReadAccess,
hasCapabilityForPrincipals,
getHeldCapabilities,
getHeldCapabilities: getHeldCapabilitiesForPrincipals,
});
module.exports = {
hasCapability,
requireCapability,
hasConfigCapability,
getHeldCapabilities,
capabilityContextMiddleware,
hasAnyConfigReadAccess: checkAnyConfigReadAccess,
getReadableConfigSections,

View file

@ -8,10 +8,15 @@ const mockUpdateSharedLinkPermissionsExpiration = jest.fn();
const mockSharedLinksAccess = jest.fn((_req, _res, next) => next());
const mockSharedLinkConfigMiddleware = jest.fn((_req, _res, next) => next());
let mockShareTenantId;
const mockHasCapability = jest.fn();
const mockHasConfigCapability = jest.fn();
const mockGetSharedLangfuseSessionUrl = jest.fn();
const mockBuildSharedLinkStartupPayload = jest.fn();
const mockCanAccessSharedLink = jest.fn((req, _res, next) => {
req.shareResourceId = 'resource-123';
req.shareTenantId = mockShareTenantId;
req.shareConversationId = 'conversation-owner-123';
req.shareOwnerId = 'owner-123';
next();
});
const mockGetAppConfig = jest.fn();
@ -93,6 +98,11 @@ jest.mock('@librechat/api', () => ({
buildShareFileEtag: (file) =>
`"share-${file.file_id}-${file.previewRevision ?? 0}-${file.bytes ?? 0}-${file.filepath ?? ''}"`,
MAX_SHARED_LINK_SEARCH_LENGTH: 256,
createSharedLangfuseSessionResolver: jest.fn(
() =>
(...args) =>
mockGetSharedLangfuseSessionUrl(...args),
),
isContentFilterError: jest.fn(
(error) =>
error?.code === 'content_filter_block' || error?.code === 'content_filter_uninspectable',
@ -105,6 +115,7 @@ jest.mock('@librechat/data-schemas', () => ({
runAsSystem: jest.fn((fn) => fn()),
tenantStorage: { run: jest.fn((_ctx, fn) => fn()) },
SYSTEM_TENANT_ID: '__SYSTEM__',
SystemCapabilities: { ACCESS_ADMIN: 'access:admin' },
}));
jest.mock('librechat-data-provider', () => ({
@ -151,9 +162,15 @@ jest.mock('~/models', () => ({
getSharedLink: jest.fn(),
getSharedLinkFile: jest.fn(),
backfillSharedLinkFiles: jest.fn(),
getMessages: jest.fn(),
getRoleByName: jest.fn(),
}));
jest.mock('~/server/middleware/roles/capabilities', () => ({
hasCapability: (...args) => mockHasCapability(...args),
hasConfigCapability: (...args) => mockHasConfigCapability(...args),
}));
const mockGetStrategyFunctions = jest.fn();
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args),
@ -233,6 +250,7 @@ const buildApp = ({
user = { id: 'user-123' },
filters,
messageFilter,
langfuse,
} = {}) => {
const app = express();
app.use(express.json());
@ -242,6 +260,7 @@ const buildApp = ({
interfaceConfig: { retentionMode },
...(filters == null ? {} : { filters }),
...(messageFilter == null ? {} : { messageFilter }),
...(langfuse == null ? {} : { langfuse }),
};
next();
});
@ -278,6 +297,9 @@ describe('share routes', () => {
beforeEach(() => {
jest.clearAllMocks();
mockShareTenantId = undefined;
mockHasCapability.mockResolvedValue(false);
mockHasConfigCapability.mockResolvedValue(false);
mockGetSharedLangfuseSessionUrl.mockResolvedValue(null);
mockGetAppConfig.mockResolvedValue({
interfaceConfig: {
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
@ -353,6 +375,88 @@ describe('share routes', () => {
expect(mockAssertSharedFileMetadataAllowed).not.toHaveBeenCalled();
});
it('includes the source conversation session for an admin in the share tenant', async () => {
mockShareTenantId = 'tenant-abc';
mockGetSharedLangfuseSessionUrl.mockResolvedValue(
'https://cloud.langfuse.com/project/project-1/sessions/conversation-owner-123',
);
mockSharedMessagesResult({ shareId: 'share-123', messages: [] });
const langfuse = { enabled: true, destination: 'eu', projectId: 'project-1' };
const response = await request(
buildApp({
user: { id: 'admin-123', role: 'ADMIN', tenantId: 'tenant-abc' },
langfuse,
}),
).get('/api/share/share-123');
expect(response.status).toBe(200);
expect(response.body.langfuseSessionUrl).toBe(
'https://cloud.langfuse.com/project/project-1/sessions/conversation-owner-123',
);
expect(mockGetSharedLangfuseSessionUrl).toHaveBeenCalledWith({
viewer: expect.objectContaining({ id: 'admin-123', tenantId: 'tenant-abc' }),
shareTenantId: 'tenant-abc',
shareConversationId: 'conversation-owner-123',
shareOwnerId: 'owner-123',
config: langfuse,
});
});
it('omits the session link when the shared-session resolver denies access', async () => {
mockShareTenantId = 'tenant-abc';
mockSharedMessagesResult({ shareId: 'share-123', messages: [] });
const response = await request(
buildApp({ user: { id: 'user-123', role: 'USER', tenantId: 'tenant-abc' } }),
).get('/api/share/share-123');
expect(response.status).toBe(200);
expect(response.body).not.toHaveProperty('langfuseSessionUrl');
});
it('serves the shared chat when session-link resolution fails', async () => {
mockShareTenantId = 'tenant-abc';
mockGetSharedLangfuseSessionUrl.mockRejectedValue(new Error('Langfuse lookup failed'));
mockSharedMessagesResult({ shareId: 'share-123', messages: [] });
const response = await request(
buildApp({ user: { id: 'admin-123', role: 'ADMIN', tenantId: 'tenant-abc' } }),
).get('/api/share/share-123');
expect(response.status).toBe(200);
expect(response.body).not.toHaveProperty('langfuseSessionUrl');
expect(logger.warn).toHaveBeenCalledWith(
'[share] Failed to resolve Langfuse session link:',
expect.any(Error),
);
});
it('resolves the session link while loading the shared snapshot', async () => {
let resolveShare;
let markShareStarted;
const shareStarted = new Promise((resolve) => {
markShareStarted = resolve;
});
const pendingShare = new Promise((resolve) => {
resolveShare = resolve;
});
getSharedMessages.mockImplementationOnce(() => {
markShareStarted();
return pendingShare;
});
mockGetSharedLangfuseSessionUrl.mockResolvedValue(null);
const responsePromise = request(buildApp())
.get('/api/share/share-123')
.then((response) => response);
await shareStarted;
expect(mockGetSharedLangfuseSessionUrl).toHaveBeenCalledTimes(1);
resolveShare({ shareId: 'share-123', messages: [] });
await expect(responsePromise).resolves.toMatchObject({ status: 200 });
});
it('normalizes shared-link list parameters without double-decoding search text', async () => {
getSharedLinks.mockResolvedValue({ links: [], hasNextPage: false });
mockParseSharedLinksPageSize.mockReturnValueOnce(100);

View file

@ -20,6 +20,7 @@ const {
isValidSharedLinksCursor,
MAX_SHARED_LINK_SEARCH_LENGTH,
createSharedLinkConfigMiddleware,
createSharedLangfuseSessionResolver,
} = require('@librechat/api');
const {
logger,
@ -38,6 +39,7 @@ const {
getSharedLink,
getSharedLinkFile,
backfillSharedLinkFiles,
getMessages,
getRoleByName,
} = require('~/models');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@ -48,11 +50,17 @@ const { createForkLimiters } = require('~/server/middleware/limiters');
const optionalShareFileAuth = require('~/server/middleware/optionalShareFileAuth');
const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { getHeldCapabilities } = require('~/server/middleware/roles/capabilities');
const configMiddleware = require('~/server/middleware/config/app');
const { getAppConfig } = require('~/server/services/Config/app');
const router = express.Router();
const sharedLinkConfigMiddleware = createSharedLinkConfigMiddleware({ getAppConfig });
const getSharedLangfuseSessionUrl = createSharedLangfuseSessionResolver({
getHeldCapabilities,
getMessages,
});
const SHARE_SERVICE_ERROR_STATUS = {
INVALID_PARAMS: 400,
TARGET_MESSAGE_NOT_FOUND: 400,
@ -335,15 +343,31 @@ if (allowSharedLinks) {
sharedFileMetadata: true,
legacyPii: req.config?.messageFilter?.pii,
});
const share = await getSharedMessages(req.params.shareId, req.shareResourceId, {
const sharePromise = getSharedMessages(req.params.shareId, req.shareResourceId, {
// Viewer-independent: the per-link choice (stored on the share) decides
// file inclusion; only a global env kill switch can force it off here.
snapshotFiles: !isFileSnapshotKillSwitchActive(),
preflight: contentPreflight,
});
const langfuseSessionPromise = getSharedLangfuseSessionUrl({
viewer: req.user,
shareTenantId: req.shareTenantId,
shareConversationId: req.shareConversationId,
shareOwnerId: req.shareOwnerId,
config: req.config?.langfuse,
}).catch((error) => {
logger.warn('[share] Failed to resolve Langfuse session link:', error);
return null;
});
const [share, langfuseSessionUrl] = await Promise.all([
sharePromise,
langfuseSessionPromise,
]);
if (share) {
res.set('Cache-Control', 'private, no-store');
res.status(200).json(share);
res
.status(200)
.json(langfuseSessionUrl == null ? share : { ...share, langfuseSessionUrl });
} else {
res.status(404).end();
}

View file

@ -491,6 +491,7 @@ export type TAuthContext = {
user: t.TUser | undefined;
token: string | undefined;
isAuthenticated: boolean;
isAuthReady: boolean;
error: string | undefined;
login: (data: t.TLoginUser) => void;
logout: (redirect?: string) => void;
@ -508,6 +509,7 @@ export type TUserContext = {
export type TAuthConfig = {
loginRedirect: string;
test?: boolean;
optional?: boolean;
};
export type IconProps = Pick<t.TMessage, 'isCreatedByUser' | 'model'> &

View file

@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import { ShareHeader } from './ShareView';
const defaultProps = {
title: 'Shared conversation',
formattedDate: 'August 27, 2026',
theme: 'system',
langcode: 'en-US',
settingsLabel: 'Settings',
continueLabel: 'Continue chat',
langfuseSessionLabel: 'View session in Langfuse',
isContinuing: false,
onContinue: jest.fn(),
onThemeChange: jest.fn(),
onLangChange: jest.fn(),
};
describe('ShareHeader', () => {
it('shows the Langfuse session as an external link when supplied', () => {
const url = 'https://cloud.langfuse.com/project/project-1/sessions/conversation-1';
render(<ShareHeader {...defaultProps} langfuseSessionUrl={url} />);
const link = screen.getByRole('link', { name: 'View session in Langfuse' });
expect(link).toHaveAttribute('href', url);
expect(link).toHaveAttribute('target', '_blank');
expect(link.parentElement).toHaveClass('flex-wrap');
});
it('omits the Langfuse action when the server does not supply a session', () => {
render(<ShareHeader {...defaultProps} />);
expect(
screen.queryByRole('link', { name: 'View session in Langfuse' }),
).not.toBeInTheDocument();
});
});

View file

@ -2,9 +2,9 @@ import { memo, useState, useCallback, useContext } from 'react';
import Cookies from 'js-cookie';
import { buildTree } from 'librechat-data-provider';
import { useParams, useNavigate } from 'react-router-dom';
import { CalendarDays, Settings, MessageSquarePlus } from 'lucide-react';
import { useRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
import { CalendarDays, ExternalLink, Settings, MessageSquarePlus } from 'lucide-react';
import {
Spinner,
Button,
@ -21,12 +21,12 @@ import {
} from '@librechat/client';
import SharedSubagentActivityDialog from '~/components/Chat/Subagents/SharedSubagentActivityDialog';
import { cn, DEFAULT_APP_TITLE, getResponseStatus, selectActiveBranchTail } from '~/utils';
import { useLocalize, useDocumentTitle, useAuthContext } from '~/hooks';
import { ThemeSelector, LangSelector } from '~/components/Appearance';
import { ShareMessagesProvider } from './ShareMessagesProvider';
import { useForkSharedConvoMutation } from '~/data-provider';
import { useGetSharedStartupConfig } from '~/data-provider';
import { ShareArtifactsContainer } from './ShareArtifacts';
import { useLocalize, useDocumentTitle } from '~/hooks';
import { ShareContext } from '~/Providers';
import MessagesView from './MessagesView';
import Footer from '../Chat/Footer';
@ -39,10 +39,13 @@ function SharedView() {
const localize = useLocalize();
const navigate = useNavigate();
const { showToast } = useToastContext();
const { isAuthReady } = useAuthContext();
const { theme, setTheme } = useContext(ThemeContext);
const { shareId } = useParams();
const { data: config } = useGetSharedStartupConfig(shareId);
const { data, isLoading, refetch } = useGetSharedMessages(shareId ?? '');
const { data: config } = useGetSharedStartupConfig(shareId, { enabled: isAuthReady });
const { data, isLoading, refetch } = useGetSharedMessages(shareId ?? '', {
enabled: isAuthReady,
});
const dataTree = data && buildTree({ messages: data.messages });
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);
@ -170,7 +173,7 @@ function SharedView() {
);
let content: JSX.Element;
if (isLoading) {
if (!isAuthReady || isLoading) {
content = (
<div className="flex h-screen items-center justify-center">
<Spinner className="" />
@ -188,6 +191,8 @@ function SharedView() {
onLangChange={handleLangChange}
settingsLabel={localize('com_nav_settings')}
continueLabel={localize('com_ui_continue_chat')}
langfuseSessionLabel={localize('com_ui_langfuse_view_session')}
langfuseSessionUrl={data.langfuseSessionUrl}
onContinue={handleContinue}
isContinuing={forkShare.isLoading}
/>
@ -275,19 +280,23 @@ interface ShareHeaderProps {
langcode: string;
settingsLabel: string;
continueLabel: string;
langfuseSessionLabel: string;
langfuseSessionUrl?: string;
isContinuing: boolean;
onContinue: () => void;
onThemeChange: (value: string) => void;
onLangChange: (value: string) => void;
}
function ShareHeader({
export function ShareHeader({
title,
formattedDate,
theme,
langcode,
settingsLabel,
continueLabel,
langfuseSessionLabel,
langfuseSessionUrl,
isContinuing,
onContinue,
onThemeChange,
@ -318,7 +327,19 @@ function ShareHeader({
)}
</div>
<div className="flex items-center gap-2 md:self-start">
<div className="flex flex-wrap items-center justify-end gap-2 md:flex-nowrap md:self-start">
{langfuseSessionUrl && (
<Button
asChild
variant="outline"
className="gap-2 rounded-full border-border-medium px-4 py-2 text-sm text-text-primary"
>
<a href={langfuseSessionUrl} target="_blank" rel="noopener noreferrer">
<span>{langfuseSessionLabel}</span>
<ExternalLink className="size-4 shrink-0" aria-hidden="true" />
</a>
</Button>
)}
<Button
type="button"
variant="submit"

View file

@ -68,6 +68,7 @@ const AuthContextProvider = ({
const [token, setToken] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [isAuthReady, setIsAuthReady] = useState<boolean>(authConfig?.test === true);
const setQueriesEnabled = useSetRecoilState<boolean>(store.queriesEnabled);
const userRoleName = user?.role ?? '';
@ -93,6 +94,7 @@ const AuthContextProvider = ({
setToken(token);
setTokenHeader(token);
setIsAuthenticated(isAuthenticated);
setIsAuthReady(true);
if (isAuthenticated) {
setQueriesEnabled(true);
/** The clear on the way out latches retention shut so a DELETE that settles afterwards
@ -235,10 +237,13 @@ const AuthContextProvider = ({
}
console.log('Token is not present. User is not authenticated.');
endSessionClientState();
setIsAuthReady(true);
if (authConfig?.test === true) {
return;
}
navigate(buildLoginRedirectUrl());
if (authConfig?.optional !== true) {
navigate(buildLoginRedirectUrl());
}
},
onError: (error) => {
if (isExternalRedirectRef.current) {
@ -246,10 +251,13 @@ const AuthContextProvider = ({
}
console.log('refreshToken mutation error:', error);
endSessionClientState();
setIsAuthReady(true);
if (authConfig?.test === true) {
return;
}
navigate(buildLoginRedirectUrl());
if (authConfig?.optional !== true) {
navigate(buildLoginRedirectUrl());
}
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- deps are stable at mount; adding refreshToken causes infinite re-fire
@ -264,7 +272,10 @@ const AuthContextProvider = ({
} else if (userQuery.isError) {
endSessionClientState();
doSetError((userQuery.error as Error).message);
navigate(buildLoginRedirectUrl(), { replace: true });
setIsAuthReady(true);
if (authConfig?.optional !== true) {
navigate(buildLoginRedirectUrl(), { replace: true });
}
}
if (error != null && error && isAuthenticated) {
doSetError(undefined);
@ -319,6 +330,7 @@ const AuthContextProvider = ({
...(isCustomRole && customRole ? { [userRoleName]: customRole } : {}),
},
isAuthenticated,
isAuthReady,
}),
/** `login` is a plain function rebuilt every render, so depending on it would rebuild this
@ -328,6 +340,7 @@ const AuthContextProvider = ({
user,
error,
isAuthenticated,
isAuthReady,
token,
userRole,
adminRole,

View file

@ -2,13 +2,11 @@
* @jest-environment @happy-dom/jest-environment
*/
import React from 'react';
import { render, act } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { render, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { TAuthConfig } from '~/common';
import { AuthContextProvider, useAuthContext } from '../AuthContext';
import { SESSION_KEY } from '~/utils';
@ -75,6 +73,7 @@ function TestConsumer() {
<div
data-testid="consumer"
data-authenticated={ctx.isAuthenticated}
data-auth-ready={ctx.isAuthReady}
data-roles={JSON.stringify(ctx.roles ?? {})}
/>
);
@ -117,6 +116,37 @@ function renderProviderLive() {
);
}
function renderOptionalProvider() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<MemoryRouter>
<AuthContextProvider authConfig={{ loginRedirect: '/login', optional: true }}>
<TestConsumer />
</AuthContextProvider>
</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>,
);
}
describe('AuthContextProvider — test mode', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('is ready without starting silent refresh', () => {
const { getByTestId } = renderProvider();
expect(getByTestId('consumer')).toHaveAttribute('data-auth-ready', 'true');
expect(mockRefreshMutate).not.toHaveBeenCalled();
});
});
describe('AuthContextProvider — login onError redirect handling', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -362,6 +392,53 @@ describe('AuthContextProvider — silentRefresh post-login redirect', () => {
});
});
describe('AuthContextProvider — optional authentication', () => {
beforeEach(() => {
jest.clearAllMocks();
window.history.replaceState({}, '', '/share/share-1');
});
afterEach(() => {
window.history.replaceState({}, '', '/');
});
it('keeps a public route visible when no refresh token exists', () => {
renderOptionalProvider();
const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [
unknown,
{ onSuccess: (data: unknown) => void },
];
act(() => {
refreshOptions.onSuccess(undefined);
});
expect(mockNavigate).not.toHaveBeenCalled();
expect(document.querySelector('[data-testid="consumer"]')).toHaveAttribute(
'data-auth-ready',
'true',
);
});
it('keeps a public route visible when session refresh fails', () => {
renderOptionalProvider();
const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [
unknown,
{ onError: (error: unknown) => void },
];
act(() => {
refreshOptions.onError(new Error('No session'));
});
expect(mockNavigate).not.toHaveBeenCalled();
expect(document.querySelector('[data-testid="consumer"]')).toHaveAttribute(
'data-auth-ready',
'true',
);
});
});
describe('AuthContextProvider — silentRefresh subdirectory deployment', () => {
beforeEach(() => {
jest.clearAllMocks();

View file

@ -1,5 +1,10 @@
import { AuthContextProvider } from '~/hooks/AuthContext';
import ShareView from '~/components/Share/ShareView';
export default function ShareRoute() {
return <ShareView />;
return (
<AuthContextProvider authConfig={{ loginRedirect: '/login', optional: true }}>
<ShareView />
</AuthContextProvider>
);
}

View file

@ -18,10 +18,11 @@ import {
getLangfuseTenantDestinations,
resolveLangfuseTenantDestination,
} from '~/langfuse/tenantDestinations';
import { getLangfuseDestinationId, scopeHeadersToDestination } from '~/langfuse/destinations';
import { redirectPolicyFor, resolveLangfuseHeaders } from '~/langfuse/utils';
import { decryptConfigSecret, encryptConfigSecretFields } from './secrets';
import { scopeHeadersToDestination } from '~/langfuse/destinations';
import { isLangfuseConnectionAvailable } from '~/langfuse/policy';
import { resolveLangfuseSessionUrl } from '~/langfuse/session';
import { mergeHeaders } from '~/utils/headers';
const DEFAULT_PRIORITY = 10;
@ -332,34 +333,13 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
}
try {
const stored = readStoredLangfuse(await findBaseConfig());
const destination = resolveLangfuseTenantDestination(stored?.destination);
const projectId = stored?.projectId?.trim();
if (stored?.enabled !== true || !destination || !projectId) {
const response: TLangfuseSessionLinkResponse = { url: null };
return res.status(200).json(response);
}
const destinationId = getLangfuseDestinationId(destination.baseUrl, projectId);
const messages = await getMessages(
{
user: userId,
conversationId,
langfuseSampled: true,
langfuseDestinationIds: destinationId,
},
'_id',
{ sort: false, limit: 1 },
);
if (messages.length === 0) {
const response: TLangfuseSessionLinkResponse = { url: null };
return res.status(200).json(response);
}
const sessionUrl = new URL(destination.baseUrl);
const basePath = sessionUrl.pathname.replace(/\/+$/, '');
sessionUrl.pathname = `${basePath}/project/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(conversationId)}`;
const response: TLangfuseSessionLinkResponse = { url: sessionUrl.toString() };
const url = await resolveLangfuseSessionUrl({
config: readStoredLangfuse(await findBaseConfig()),
conversationId,
userId,
getMessages,
});
const response: TLangfuseSessionLinkResponse = { url };
return res.status(200).json(response);
} catch (error) {
logger.error('[adminLangfuse] getSessionLink error:', error);

View file

@ -98,6 +98,7 @@ export * from './shared-links/service';
export * from './shared-links/config';
export * from './shared-links/http';
export * from './shared-links/protection';
export * from './shared-links/session';
/* Stream */
export * from './stream';
/* Diagnostics */

View file

@ -2,3 +2,4 @@ export * from './destinations';
export * from './feedback';
export * from './policy';
export * from './trace';
export * from './session';

View file

@ -0,0 +1,49 @@
import type { MessageMethods } from '@librechat/data-schemas';
import type { TCustomConfig } from 'librechat-data-provider';
import { resolveLangfuseTenantDestination } from './tenantDestinations';
import { getLangfuseDestinationId } from './destinations';
import { isLangfuseConnectionAvailable } from './policy';
export interface LangfuseSessionLinkParams {
config: TCustomConfig['langfuse'];
conversationId: string;
userId: string;
getMessages: MessageMethods['getMessages'];
}
export async function resolveLangfuseSessionUrl({
config,
conversationId,
userId,
getMessages,
}: LangfuseSessionLinkParams): Promise<string | null> {
if (!isLangfuseConnectionAvailable()) {
return null;
}
const destination = resolveLangfuseTenantDestination(config?.destination);
const projectId = config?.projectId?.trim();
if (config?.enabled !== true || !destination || !projectId) {
return null;
}
const destinationId = getLangfuseDestinationId(destination.baseUrl, projectId);
const messages = await getMessages(
{
user: userId,
conversationId,
langfuseSampled: true,
langfuseDestinationIds: destinationId,
},
'_id',
{ sort: false, limit: 1 },
);
if (messages.length === 0) {
return null;
}
const sessionUrl = new URL(destination.baseUrl);
const basePath = sessionUrl.pathname.replace(/\/+$/, '');
sessionUrl.pathname = `${basePath}/project/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(conversationId)}`;
return sessionUrl.toString();
}

View file

@ -31,15 +31,47 @@ const userPrincipals = [
describe('generateCapabilityCheck', () => {
const mockGetUserPrincipals = jest.fn();
const mockHasCapabilityForPrincipals = jest.fn();
const mockGetHeldCapabilities = jest.fn();
const { hasCapability, requireCapability, hasConfigCapability } = generateCapabilityCheck({
getUserPrincipals: mockGetUserPrincipals,
hasCapabilityForPrincipals: mockHasCapabilityForPrincipals,
});
const { hasCapability, requireCapability, hasConfigCapability, getHeldCapabilities } =
generateCapabilityCheck({
getUserPrincipals: mockGetUserPrincipals,
hasCapabilityForPrincipals: mockHasCapabilityForPrincipals,
getHeldCapabilities: mockGetHeldCapabilities,
});
beforeEach(() => {
mockGetUserPrincipals.mockReset();
mockHasCapabilityForPrincipals.mockReset();
mockGetHeldCapabilities.mockReset();
});
describe('getHeldCapabilities', () => {
it('resolves principals once and checks all requested capabilities in one batch', async () => {
const capabilities = [
SystemCapabilities.ACCESS_ADMIN,
SystemCapabilities.MANAGE_CONFIGS,
configCapability('langfuse'),
];
const held = new Set([SystemCapabilities.ACCESS_ADMIN, configCapability('langfuse')]);
mockGetUserPrincipals.mockResolvedValue(adminPrincipals);
mockGetHeldCapabilities.mockResolvedValue(held);
const result = await getHeldCapabilities(
{ id: 'user-123', role: 'ADMIN', tenantId: 'tenant-1' },
capabilities,
);
expect(result).toBe(held);
expect(mockGetUserPrincipals).toHaveBeenCalledTimes(1);
expect(mockGetHeldCapabilities).toHaveBeenCalledTimes(1);
expect(mockGetHeldCapabilities).toHaveBeenCalledWith({
principals: adminPrincipals,
capabilities,
tenantId: 'tenant-1',
});
expect(mockHasCapabilityForPrincipals).not.toHaveBeenCalled();
});
});
describe('hasCapability', () => {

View file

@ -69,6 +69,11 @@ export type HasConfigCapabilityFn = (
verb?: 'manage' | 'read',
) => Promise<boolean>;
export type GetHeldCapabilitiesFn = (
user: CapabilityUser,
capabilities: SystemCapability[],
) => Promise<Set<SystemCapability>>;
/**
* Per-request store for caching resolved principals and capability check results.
* When running inside an Express request (via `capabilityContextMiddleware`),
@ -141,6 +146,7 @@ export function generateCapabilityCheck(deps: CapabilityDeps): {
hasCapability: HasCapabilityFn;
requireCapability: RequireCapabilityFn;
hasConfigCapability: HasConfigCapabilityFn;
getHeldCapabilities: GetHeldCapabilitiesFn;
hasAnyConfigReadAccess: (user: CapabilityUser) => Promise<boolean>;
getReadableConfigSections: GetReadableConfigSectionsFn;
} {
@ -175,6 +181,14 @@ export function generateCapabilityCheck(deps: CapabilityDeps): {
return checkAny({ principals, tenantId: user.tenantId });
}
async function getHeldCapabilities(
user: CapabilityUser,
capabilities: SystemCapability[],
): Promise<Set<SystemCapability>> {
const principals = await resolvePrincipals(user);
return getHeldCaps({ principals, capabilities, tenantId: user.tenantId });
}
/**
* Resolves which of `sections` the user can read in a single batched
* query, instead of one `hasConfigCapability` round trip per section.
@ -183,17 +197,12 @@ export function generateCapabilityCheck(deps: CapabilityDeps): {
user: CapabilityUser,
sections: ConfigSection[],
): Promise<{ broad: boolean; sections: Set<string> }> {
const principals = await resolvePrincipals(user);
const capsToCheck = [
SystemCapabilities.READ_CONFIGS,
SystemCapabilities.MANAGE_CONFIGS,
...sections.map(readConfigCapability),
];
const held = await getHeldCaps({
principals,
capabilities: capsToCheck,
tenantId: user.tenantId,
});
const held = await getHeldCapabilities(user, capsToCheck);
const broad =
held.has(SystemCapabilities.READ_CONFIGS) || held.has(SystemCapabilities.MANAGE_CONFIGS);
const readableSections = new Set(
@ -298,6 +307,7 @@ export function generateCapabilityCheck(deps: CapabilityDeps): {
hasCapability,
requireCapability,
hasConfigCapability,
getHeldCapabilities,
hasAnyConfigReadAccess,
getReadableConfigSections,
};

View file

@ -200,7 +200,11 @@ describe('canAccessSharedLink', () => {
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(next).toHaveBeenCalled();
expect((req as unknown as Record<string, unknown>).shareResourceId).toBe(link._id.toString());
expect(req).toMatchObject({
shareResourceId: link._id.toString(),
shareConversationId: 'convo1',
shareOwnerId: userId.toString(),
});
});
test('returns 401 for anonymous access when ALLOW_SHARED_LINKS_PUBLIC is not set', async () => {

View file

@ -26,6 +26,8 @@ interface RawSharedLink {
interface SharedLinkAccessRequest extends Request {
shareResourceId?: string;
shareTenantId?: string;
shareConversationId?: string;
shareOwnerId?: string;
}
export interface SharedLinkAccessDeps {
@ -94,6 +96,8 @@ export function createSharedLinkAccessMiddleware(deps: SharedLinkAccessDeps) {
const sharedRequest = req as SharedLinkAccessRequest;
sharedRequest.shareResourceId = resourceId;
sharedRequest.shareTenantId = rawShare.tenantId;
sharedRequest.shareConversationId = rawShare.conversationId;
sharedRequest.shareOwnerId = rawShare.user;
next();
};

View file

@ -0,0 +1,107 @@
import { configCapability, SystemCapabilities, type MessageMethods } from '@librechat/data-schemas';
import { createSharedLangfuseSessionResolver } from './session';
const getHeldCapabilities = jest.fn();
const getMessages = jest.fn() as unknown as MessageMethods['getMessages'];
const resolveSessionUrl = jest.fn();
const config = {
enabled: true,
destination: 'eu',
projectId: 'project-1',
};
function createResolver() {
return createSharedLangfuseSessionResolver({
getHeldCapabilities,
getMessages,
resolveSessionUrl,
});
}
const validParams = {
viewer: { id: 'admin-1', role: 'ADMIN', tenantId: 'tenant-1' },
shareTenantId: 'tenant-1',
shareConversationId: 'conversation-1',
shareOwnerId: 'owner-1',
config,
};
describe('createSharedLangfuseSessionResolver', () => {
beforeEach(() => {
jest.clearAllMocks();
getHeldCapabilities.mockResolvedValue(
new Set([SystemCapabilities.ACCESS_ADMIN, SystemCapabilities.MANAGE_CONFIGS]),
);
resolveSessionUrl.mockResolvedValue('https://cloud.langfuse.com/session-1');
});
it('resolves the source session for an authorized same-tenant admin', async () => {
const result = await createResolver()(validParams);
expect(result).toBe('https://cloud.langfuse.com/session-1');
expect(getHeldCapabilities).toHaveBeenCalledWith(
expect.objectContaining({ id: 'admin-1', tenantId: 'tenant-1' }),
[
SystemCapabilities.ACCESS_ADMIN,
SystemCapabilities.MANAGE_CONFIGS,
configCapability('langfuse'),
],
);
expect(resolveSessionUrl).toHaveBeenCalledWith({
config,
conversationId: 'conversation-1',
userId: 'owner-1',
getMessages,
});
});
it('rejects a viewer from another tenant before capability checks', async () => {
const result = await createResolver()({
...validParams,
viewer: { ...validParams.viewer, tenantId: 'tenant-2' },
});
expect(result).toBeNull();
expect(getHeldCapabilities).not.toHaveBeenCalled();
expect(resolveSessionUrl).not.toHaveBeenCalled();
});
it('rejects a viewer without admin access', async () => {
getHeldCapabilities.mockResolvedValue(new Set([SystemCapabilities.MANAGE_CONFIGS]));
const result = await createResolver()(validParams);
expect(result).toBeNull();
expect(resolveSessionUrl).not.toHaveBeenCalled();
});
it('rejects an admin without Langfuse config access', async () => {
getHeldCapabilities.mockResolvedValue(new Set([SystemCapabilities.ACCESS_ADMIN]));
const result = await createResolver()(validParams);
expect(result).toBeNull();
expect(resolveSessionUrl).not.toHaveBeenCalled();
});
it('accepts an admin with section-specific Langfuse config access', async () => {
getHeldCapabilities.mockResolvedValue(
new Set([SystemCapabilities.ACCESS_ADMIN, configCapability('langfuse')]),
);
const result = await createResolver()(validParams);
expect(result).toBe('https://cloud.langfuse.com/session-1');
});
it('treats the system tenant and an omitted tenant as the same deployment', async () => {
const result = await createResolver()({
...validParams,
viewer: { id: 'admin-1', role: 'ADMIN' },
shareTenantId: '__SYSTEM__',
});
expect(result).toBe('https://cloud.langfuse.com/session-1');
});
});

View file

@ -0,0 +1,81 @@
import { configCapability, SYSTEM_TENANT_ID, SystemCapabilities } from '@librechat/data-schemas';
import type { TCustomConfig } from 'librechat-data-provider';
import type { CapabilityUser, GetHeldCapabilitiesFn } from '~/middleware/capabilities';
import type { LangfuseSessionLinkParams } from '~/langfuse/session';
import { resolveLangfuseSessionUrl } from '~/langfuse/session';
interface SharedSessionViewer {
id?: string;
_id?: { toString(): string };
role?: string;
tenantId?: string;
idOnTheSource?: string | null;
}
export interface SharedLangfuseSessionParams {
viewer?: SharedSessionViewer;
shareTenantId?: string;
shareConversationId?: string;
shareOwnerId?: string;
config: TCustomConfig['langfuse'];
}
export interface SharedLangfuseSessionDeps {
getHeldCapabilities: GetHeldCapabilitiesFn;
getMessages: LangfuseSessionLinkParams['getMessages'];
resolveSessionUrl?: (params: LangfuseSessionLinkParams) => Promise<string | null>;
}
function normalizeTenantId(tenantId?: string): string | undefined {
return tenantId && tenantId !== SYSTEM_TENANT_ID ? tenantId : undefined;
}
export function createSharedLangfuseSessionResolver(deps: SharedLangfuseSessionDeps) {
const resolveSessionUrl = deps.resolveSessionUrl ?? resolveLangfuseSessionUrl;
return async function getSharedLangfuseSessionUrl({
viewer,
shareTenantId,
shareConversationId,
shareOwnerId,
config,
}: SharedLangfuseSessionParams): Promise<string | null> {
const userId = viewer?.id ?? viewer?._id?.toString();
if (
!viewer ||
!userId ||
normalizeTenantId(viewer?.tenantId) !== normalizeTenantId(shareTenantId) ||
!shareOwnerId ||
!shareConversationId
) {
return null;
}
const capabilityUser: CapabilityUser = {
id: userId,
role: viewer.role ?? '',
tenantId: viewer.tenantId,
idOnTheSource: viewer.idOnTheSource ?? null,
};
const langfuseConfigCapability = configCapability('langfuse');
const heldCapabilities = await deps.getHeldCapabilities(capabilityUser, [
SystemCapabilities.ACCESS_ADMIN,
SystemCapabilities.MANAGE_CONFIGS,
langfuseConfigCapability,
]);
if (
!heldCapabilities.has(SystemCapabilities.ACCESS_ADMIN) ||
(!heldCapabilities.has(SystemCapabilities.MANAGE_CONFIGS) &&
!heldCapabilities.has(langfuseConfigCapability))
) {
return null;
}
return resolveSessionUrl({
config,
conversationId: shareConversationId,
userId: shareOwnerId,
getMessages: deps.getMessages,
});
};
}

View file

@ -444,6 +444,7 @@ export type TPinConversationResponse = TConversation;
export type TSharedMessagesResponse = Omit<TSharedLink, 'messages'> & {
messages: TMessage[];
langfuseSessionUrl?: string;
};
export type TCreateShareLinkRequest = Pick<TConversation, 'conversationId'>;