🪢 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

@ -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,
});
};
}