mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🪢 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
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:
parent
03fa98eb37
commit
a70bcf66a4
20 changed files with 614 additions and 59 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'> &
|
||||
|
|
|
|||
37
client/src/components/Share/ShareView.spec.tsx
Normal file
37
client/src/components/Share/ShareView.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export * from './destinations';
|
|||
export * from './feedback';
|
||||
export * from './policy';
|
||||
export * from './trace';
|
||||
export * from './session';
|
||||
|
|
|
|||
49
packages/api/src/langfuse/session.ts
Normal file
49
packages/api/src/langfuse/session.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
107
packages/api/src/shared-links/session.spec.ts
Normal file
107
packages/api/src/shared-links/session.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
81
packages/api/src/shared-links/session.ts
Normal file
81
packages/api/src/shared-links/session.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -444,6 +444,7 @@ export type TPinConversationResponse = TConversation;
|
|||
|
||||
export type TSharedMessagesResponse = Omit<TSharedLink, 'messages'> & {
|
||||
messages: TMessage[];
|
||||
langfuseSessionUrl?: string;
|
||||
};
|
||||
|
||||
export type TCreateShareLinkRequest = Pick<TConversation, 'conversationId'>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue