Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cc69f4c67 | ||
|
|
557f284cd1 | ||
|
|
6702c7b50f | ||
|
|
25d99aa371 | ||
|
|
e4d5f914cc | ||
|
|
dcb5dbf528 | ||
|
|
d003a9c3f4 | ||
|
|
d2d56f0337 | ||
|
|
6c0cf07a5a |
@@ -145,6 +145,11 @@ LANGFUSE_AI_FEATURES_SECRET_KEY="sk-lf-1234567890"
|
||||
LANGFUSE_AI_FEATURES_HOST="http://localhost:3000"
|
||||
LANGFUSE_AI_FEATURES_PROJECT_ID=7a88fb47-b4e2-43b8-a06c-a5ce950dc53a
|
||||
|
||||
# Self-hosted only: allow internal LLM proxy hosts/IPs for LLM connection base URLs.
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST=localhost
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS=127.0.0.1,::1
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS=127.0.0.0/8
|
||||
|
||||
# Langfuse AI Bedrock credentials
|
||||
AWS_ACCESS_KEY_ID="A123456789"
|
||||
AWS_SECRET_ACCESS_KEY="SAK123456789"
|
||||
|
||||
@@ -299,6 +299,10 @@ LANGFUSE_ENABLE_BLOB_STORAGE_FILE_LOG=true
|
||||
|
||||
# Admin API
|
||||
# ADMIN_API_KEY=
|
||||
# Self-hosted only: allow internal LLM proxy hosts/IPs for LLM connection base URLs.
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST=
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS=
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS=
|
||||
# LANGFUSE_CACHE_MODEL_MATCH_ENABLED=
|
||||
# LANGFUSE_CACHE_MODEL_MATCH_TTL_SECONDS=
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ updates:
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 8
|
||||
default-days: 5
|
||||
versioning-strategy: "increase"
|
||||
commit-message:
|
||||
prefix: chore
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.2",
|
||||
"next-auth": "^4.24.13",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.167.0",
|
||||
"version": "3.167.1",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-config-turbo": "2.9.5",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.167.0";
|
||||
export const VERSION = "v3.167.1";
|
||||
|
||||
@@ -261,6 +261,24 @@ const EnvSchema = z.object({
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
SLACK_CLIENT_ID: z.string().optional(),
|
||||
SLACK_CLIENT_SECRET: z.string().optional(),
|
||||
SLACK_STATE_SECRET: z.string().optional(),
|
||||
@@ -272,6 +290,14 @@ const EnvSchema = z.object({
|
||||
.describe(
|
||||
"How many records should be fetched from Slack, before we give up",
|
||||
),
|
||||
SLACK_PAGE_SIZE: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.int()
|
||||
.max(1000)
|
||||
.optional()
|
||||
.default(1000) // Use high default to minimize number of API calls and hence avoid rate limits
|
||||
.describe("Number of channels to fetch per Slack API page"),
|
||||
HTTPS_PROXY: z.string().optional(),
|
||||
|
||||
LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT: z.coerce
|
||||
|
||||
@@ -54,9 +54,11 @@ export type AuthHeaderValidVerificationResultIngestion = {
|
||||
scope: ApiAccessScopeIngestion;
|
||||
};
|
||||
|
||||
export type ApiAccessLevel = "organization" | "project" | "scores";
|
||||
|
||||
type BaseApiAccessScope = {
|
||||
projectId: string | null;
|
||||
accessLevel: "organization" | "project" | "scores";
|
||||
accessLevel: ApiAccessLevel;
|
||||
};
|
||||
|
||||
type ApiAccessScopeMetadata = {
|
||||
|
||||
@@ -30,6 +30,7 @@ export * from "./llm/utils";
|
||||
export * from "./llm/types";
|
||||
export * from "./llm/compileChatMessages";
|
||||
export * from "./llm/testModelCall";
|
||||
export * from "./llm/baseUrlValidation";
|
||||
export * from "./llm/getInternalTracingHandler";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
export * from "./utils/transforms";
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { URL } from "node:url";
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../logger";
|
||||
import {
|
||||
isHostnameBlocked,
|
||||
isIPBlocked,
|
||||
isIPAddress,
|
||||
} from "../webhooks/ipBlocking";
|
||||
import { resolveHost } from "../webhooks/validation";
|
||||
|
||||
export interface LlmBaseUrlValidationWhitelist {
|
||||
hosts: string[];
|
||||
ips: string[];
|
||||
ip_ranges: string[];
|
||||
}
|
||||
|
||||
export function llmBaseUrlWhitelistFromEnv(): LlmBaseUrlValidationWhitelist {
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
return {
|
||||
hosts: [],
|
||||
ips: [],
|
||||
ip_ranges: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hosts: env.LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST || [],
|
||||
ips: env.LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS || [],
|
||||
ip_ranges: env.LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS || [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateLlmConnectionBaseURL(
|
||||
urlString: string,
|
||||
whitelist: LlmBaseUrlValidationWhitelist = llmBaseUrlWhitelistFromEnv(),
|
||||
): Promise<void> {
|
||||
const effectiveWhitelist = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
? {
|
||||
hosts: [],
|
||||
ips: [],
|
||||
ip_ranges: [],
|
||||
}
|
||||
: whitelist;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(normalizeURL(urlString));
|
||||
} catch {
|
||||
throw new Error("Invalid URL syntax");
|
||||
}
|
||||
|
||||
if (!["https:", "http:"].includes(url.protocol)) {
|
||||
throw new Error("Only HTTP and HTTPS protocols are allowed");
|
||||
}
|
||||
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
|
||||
if (effectiveWhitelist.hosts.includes(hostname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHostnameBlocked(hostname)) {
|
||||
throw new Error("Blocked hostname detected");
|
||||
}
|
||||
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && url.protocol !== "https:") {
|
||||
throw new Error("Only HTTPS base URLs are allowed on Langfuse Cloud");
|
||||
}
|
||||
|
||||
if (isIPAddress(hostname)) {
|
||||
if (
|
||||
isIPBlocked(
|
||||
hostname,
|
||||
effectiveWhitelist.ips,
|
||||
effectiveWhitelist.ip_ranges,
|
||||
)
|
||||
) {
|
||||
logger.warn(
|
||||
`LLM base URL validation blocked IP address in hostname: ${hostname}`,
|
||||
);
|
||||
throw new Error("Blocked IP address detected");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let ips: string[];
|
||||
try {
|
||||
ips = await resolveHost(hostname);
|
||||
} catch {
|
||||
// DNS resolution is best-effort here so valid custom gateways do not fail at write time.
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ip of ips) {
|
||||
if (isIPBlocked(ip, effectiveWhitelist.ips, effectiveWhitelist.ip_ranges)) {
|
||||
logger.warn(
|
||||
`LLM base URL validation blocked resolved IP address: ${ip} for hostname: ${hostname}`,
|
||||
);
|
||||
throw new Error("Blocked IP address detected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeURL(urlString: string): string {
|
||||
let normalized = urlString.trim();
|
||||
|
||||
try {
|
||||
normalized = decodeURIComponent(normalized);
|
||||
} catch {
|
||||
throw new Error("Invalid URL encoding");
|
||||
}
|
||||
|
||||
try {
|
||||
normalized = normalized.normalize("NFC");
|
||||
} catch {
|
||||
throw new Error("Invalid unicode in URL");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
let normalized = hostname.toLowerCase();
|
||||
|
||||
try {
|
||||
normalized = new URL(`http://${normalized}`).hostname;
|
||||
} catch {
|
||||
// Keep the original hostname so URL parsing can fail consistently elsewhere.
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import chunk from "lodash/chunk";
|
||||
|
||||
import { prisma } from "../db";
|
||||
|
||||
const BATCH_SIZE = 10_000;
|
||||
|
||||
interface MediaFileRef {
|
||||
id: string;
|
||||
bucketPath: string;
|
||||
@@ -55,16 +59,20 @@ export async function deleteMediaFiles(params: {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Delete from S3 first
|
||||
await storageClient.deleteFiles(mediaFiles.map((f) => f.bucketPath));
|
||||
|
||||
// Delete from PostgreSQL (cascades to traceMedia/observationMedia)
|
||||
await prisma.media.deleteMany({
|
||||
where: {
|
||||
id: { in: mediaFiles.map((f) => f.id) },
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
// Process in batches to stay under PostgreSQL's 32,767 bind variable limit.
|
||||
// S3 is deleted before PG per batch to avoid orphaned storage files.
|
||||
// All callers target expired or soft-deleted media with retry semantics,
|
||||
// so partial failure self-heals on retry (S3 deletes are idempotent).
|
||||
const chunks = chunk(mediaFiles, BATCH_SIZE);
|
||||
for (const batch of chunks) {
|
||||
await storageClient.deleteFiles(batch.map((f) => f.bucketPath));
|
||||
await prisma.media.deleteMany({
|
||||
where: {
|
||||
id: { in: batch.map((f) => f.id) },
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return mediaFiles.length;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,39 @@ import { env } from "../../env";
|
||||
import { prisma } from "../../db";
|
||||
import { encrypt, decrypt } from "../../encryption";
|
||||
|
||||
/**
|
||||
* Error thrown by SlackService when a Slack API call fails.
|
||||
* Preserves the Slack error code so callers can provide user-friendly messages.
|
||||
*/
|
||||
export class SlackApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly slackErrorCode?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SlackApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/** OAuth scopes requested when installing the Slack app. */
|
||||
export const SLACK_BOT_SCOPES = [
|
||||
"channels:read", // read public channels
|
||||
"groups:read", // read private channels that the bot is a member of
|
||||
"chat:write", // send messages to channels the bot is a member of
|
||||
"chat:write.public", // send messages to public channels that the bot is not a member of
|
||||
] as const;
|
||||
|
||||
// Types for Slack integration
|
||||
export interface SlackChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isMember: boolean;
|
||||
isPrivate?: boolean;
|
||||
isMember?: boolean;
|
||||
}
|
||||
|
||||
export interface GetChannelsResult {
|
||||
channels: SlackChannel[];
|
||||
hasPrivateChannelAccess: boolean;
|
||||
}
|
||||
|
||||
export interface SlackMessageParams {
|
||||
@@ -96,7 +123,7 @@ export class SlackService {
|
||||
clientSecret: env.SLACK_CLIENT_SECRET!,
|
||||
stateSecret: env.SLACK_STATE_SECRET!,
|
||||
installUrlOptions: {
|
||||
scopes: ["channels:read", "chat:write", "chat:write.public"],
|
||||
scopes: SLACK_BOT_SCOPES as unknown as string[],
|
||||
},
|
||||
installationStore: {
|
||||
storeInstallation: async (installation) => {
|
||||
@@ -280,7 +307,9 @@ export class SlackService {
|
||||
throw new Error("No bot token found for project");
|
||||
}
|
||||
|
||||
const client = new WebClient(auth.botToken);
|
||||
const client = new WebClient(auth.botToken, {
|
||||
retryConfig: { retries: 3, maxRetryTime: 90_000 },
|
||||
});
|
||||
logger.debug("Created WebClient for project", { projectId });
|
||||
|
||||
return client;
|
||||
@@ -301,14 +330,15 @@ export class SlackService {
|
||||
*/
|
||||
private async getChannelsRecursive(
|
||||
client: WebClient,
|
||||
channelTypes: string = "public_channel,private_channel",
|
||||
cursor?: string,
|
||||
fetchedRecords: number = 0,
|
||||
): Promise<SlackChannel[]> {
|
||||
try {
|
||||
const result = await client.conversations.list({
|
||||
exclude_archived: true,
|
||||
types: "public_channel",
|
||||
limit: 200,
|
||||
types: channelTypes,
|
||||
limit: env.SLACK_PAGE_SIZE,
|
||||
cursor: cursor,
|
||||
});
|
||||
|
||||
@@ -333,10 +363,11 @@ export class SlackService {
|
||||
try {
|
||||
const nextPageChannels = await this.getChannelsRecursive(
|
||||
client,
|
||||
channelTypes,
|
||||
nextCursor,
|
||||
fetchedRecords + channels.length,
|
||||
);
|
||||
return [...channels, ...nextPageChannels];
|
||||
return channels.concat(nextPageChannels);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to retrieve next page of channels, returning only already fetched`,
|
||||
@@ -347,6 +378,55 @@ export class SlackService {
|
||||
return channels;
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch channels recursively", { error, cursor });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channels accessible to the bot.
|
||||
*/
|
||||
async getChannels(client: WebClient): Promise<GetChannelsResult> {
|
||||
try {
|
||||
const channels = await this.getChannelsRecursive(
|
||||
client,
|
||||
"public_channel,private_channel",
|
||||
);
|
||||
|
||||
logger.debug("Retrieved channels from Slack", {
|
||||
channelCount: channels.length,
|
||||
});
|
||||
|
||||
return { channels, hasPrivateChannelAccess: true };
|
||||
} catch (error: any) {
|
||||
// we added `groups:read` scope after initial release, so older installations may not have it.
|
||||
// Detect this case and fall back to fetching only public channels instead of failing completely.
|
||||
const isMissingGroupsRead =
|
||||
error?.data?.error === "missing_scope" &&
|
||||
error?.data?.needed === "groups:read";
|
||||
|
||||
if (isMissingGroupsRead) {
|
||||
logger.info(
|
||||
"Bot token lacks groups:read scope, falling back to public channels only",
|
||||
);
|
||||
|
||||
try {
|
||||
const channels = await this.getChannelsRecursive(
|
||||
client,
|
||||
"public_channel",
|
||||
);
|
||||
|
||||
return { channels, hasPrivateChannelAccess: false };
|
||||
} catch (fallbackError) {
|
||||
logger.error("Failed to fetch public channels fallback", {
|
||||
error: fallbackError,
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to fetch channels: ${fallbackError instanceof Error ? fallbackError.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("Failed to fetch channels", { error });
|
||||
throw new Error(
|
||||
`Failed to fetch channels: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
@@ -354,22 +434,24 @@ export class SlackService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channels accessible to the bot
|
||||
* Get channel info by ID via conversations.info.
|
||||
*/
|
||||
async getChannels(client: WebClient): Promise<SlackChannel[]> {
|
||||
async getChannelInfo(
|
||||
client: WebClient,
|
||||
channelId: string,
|
||||
): Promise<SlackChannel | null> {
|
||||
try {
|
||||
const channels = await this.getChannelsRecursive(client);
|
||||
|
||||
logger.debug("Retrieved channels from Slack", {
|
||||
channelCount: channels.length,
|
||||
});
|
||||
|
||||
return channels;
|
||||
const result = await client.conversations.info({ channel: channelId });
|
||||
if (!result.ok || !result.channel) return null;
|
||||
return {
|
||||
id: result.channel.id!,
|
||||
name: result.channel.name!,
|
||||
isPrivate: result.channel.is_private || false,
|
||||
isMember: result.channel.is_member || false,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch channels", { error });
|
||||
throw new Error(
|
||||
`Failed to fetch channels: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
logger.warn("Failed to fetch channel info", { error, channelId });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,13 +483,16 @@ export class SlackService {
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error("Failed to send message", {
|
||||
error,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
throw new Error(
|
||||
|
||||
const slackErrorCode = error?.data?.error as string | undefined;
|
||||
throw new SlackApiError(
|
||||
`Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
slackErrorCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +50,14 @@ export function isIPBlocked(
|
||||
whiteListedIpSegments: string[],
|
||||
): boolean {
|
||||
try {
|
||||
const cleanedIp = normalizeIPAddress(ipString);
|
||||
|
||||
// Check if IP is in whitelist first
|
||||
if (whitelistedIPs.includes(ipString.toLowerCase().trim())) {
|
||||
if (whitelistedIPs.includes(cleanedIp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ip = ipaddr.parse(ipString);
|
||||
const ip = ipaddr.parse(cleanedIp);
|
||||
|
||||
const whitelistedSegments = whiteListedIpSegments.map((cidr) => {
|
||||
const [addr, bits] = cidr.split("/");
|
||||
@@ -87,8 +89,7 @@ export function isIPBlocked(
|
||||
* Check if a string is an IP address
|
||||
*/
|
||||
export function isIPAddress(hostname: string): boolean {
|
||||
// Remove brackets from IPv6 addresses
|
||||
const cleaned = hostname.replace(/^\[|\]$/g, "");
|
||||
const cleaned = normalizeIPAddress(hostname);
|
||||
|
||||
try {
|
||||
ipaddr.parse(cleaned);
|
||||
@@ -137,3 +138,10 @@ export function isHostnameBlocked(hostname: string): boolean {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeIPAddress(ipString: string): string {
|
||||
return ipString
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/^\[|\]$/g, "");
|
||||
}
|
||||
|
||||
Generated
+314
-442
File diff suppressed because it is too large
Load Diff
+3
-63
@@ -4,72 +4,12 @@ packages:
|
||||
- "worker"
|
||||
- "packages/**"
|
||||
- "ee"
|
||||
# 8 day delay for new dep upgrades to reduce supply chain attack risk
|
||||
minimumReleaseAge: 11520
|
||||
# 5 day delay for new dep upgrades to reduce supply chain attack risk
|
||||
minimumReleaseAge: 7200
|
||||
# TODO: remove exclusions below!
|
||||
# the exclusions are temporary so that we can set the 8 day limit without downgrading packages.
|
||||
# the exclusions are temporary so that we can set the 5 day limit without downgrading packages.
|
||||
# this list is version-specific
|
||||
minimumReleaseAgeExclude:
|
||||
- "picomatch@4.0.4"
|
||||
- "graphql@16.13.2"
|
||||
- "use-sync-external-store@1.6.0"
|
||||
- "release-it@19.2.4"
|
||||
- "@codemirror/language@6.12.3"
|
||||
- "@sentry/core@10.46.0"
|
||||
- "@sentry/node-core@10.46.0"
|
||||
- "@sentry-internal/browser-utils@10.46.0"
|
||||
- "@sentry-internal/replay@10.46.0"
|
||||
- "@sentry/opentelemetry@10.46.0"
|
||||
- "@sentry-internal/feedback@10.46.0"
|
||||
- "@sentry-internal/replay-canvas@10.46.0"
|
||||
- "@sentry/browser@10.46.0"
|
||||
- "@sentry/node@10.46.0"
|
||||
- "@sentry/react@10.46.0"
|
||||
- "@sentry/vercel-edge@10.46.0"
|
||||
- "@sentry/nextjs@10.46.0"
|
||||
- "@opentelemetry/context-async-hooks@2.6.1"
|
||||
- "@opentelemetry/core@2.6.1"
|
||||
- "@opentelemetry/resources@2.6.1"
|
||||
- "@opentelemetry/sdk-trace-base@2.6.1"
|
||||
- "undici@7.24.6"
|
||||
- "eslint-plugin-react-hooks@7.0.1"
|
||||
- "react-is@19.2.4"
|
||||
- "@next/swc-darwin-arm64@16.2.1"
|
||||
- "@next/swc-darwin-x64@16.2.1"
|
||||
- "@next/swc-linux-arm64-gnu@16.2.1"
|
||||
- "@next/swc-linux-arm64-musl@16.2.1"
|
||||
- "@next/swc-linux-x64-gnu@16.2.1"
|
||||
- "@next/swc-linux-x64-musl@16.2.1"
|
||||
- "@next/swc-win32-arm64-msvc@16.2.1"
|
||||
- "@next/swc-win32-x64-msvc@16.2.1"
|
||||
- "eslint-config-next@16.2.1"
|
||||
- "@next/eslint-plugin-next@16.2.1"
|
||||
- "@next/env@16.2.1"
|
||||
- "next@16.2.1"
|
||||
- "@vitest/pretty-format@4.1.2"
|
||||
- "@vitest/spy@4.1.2"
|
||||
- "@vitest/utils@4.1.2"
|
||||
- "@vitest/mocker@4.1.2"
|
||||
- "@vitest/runner@4.1.2"
|
||||
- "@vitest/snapshot@4.1.2"
|
||||
- "@vitest/expect@4.1.2"
|
||||
- "vitest@4.1.2"
|
||||
- "@vitest/coverage-v8@4.1.2"
|
||||
- "path-to-regexp@8.3.0"
|
||||
- "lodash@4.17.23"
|
||||
- "zod-to-json-schema@3.25.2"
|
||||
- "langfuse@3.38.20"
|
||||
- "langfuse-langchain@3.38.20"
|
||||
- "langfuse-core@3.38.20"
|
||||
- "@modelcontextprotocol/sdk@1.29.0"
|
||||
- "@prisma/instrumentation@6.19.3"
|
||||
- "prisma@6.19.3"
|
||||
- "@prisma/client@6.19.3"
|
||||
- "@prisma/config@6.19.3"
|
||||
- "@prisma/engines@6.19.3"
|
||||
- "@prisma/debug@6.19.3"
|
||||
- "@prisma/fetch-engine@6.19.3"
|
||||
- "@prisma/get-platform@6.19.3"
|
||||
- "eslint-config-turbo@2.9.5"
|
||||
- "eslint-plugin-turbo@2.9.5"
|
||||
- "turbo@2.9.5"
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.167.0",
|
||||
"version": "3.167.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -127,7 +127,7 @@
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.552.0",
|
||||
"nanoid": "^3.3.11",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.2",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-query-params": "^5.1.0",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -192,7 +192,7 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20260122.3",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"node-mocks-http": "^1.14.1",
|
||||
|
||||
@@ -13,7 +13,7 @@ import { LLMAdapter } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import { decrypt } from "@langfuse/shared/encryption";
|
||||
import { decrypt, encrypt } from "@langfuse/shared/encryption";
|
||||
import {
|
||||
createOrgProjectAndApiKey,
|
||||
fetchLLMCompletion,
|
||||
@@ -140,6 +140,18 @@ describe("llmApiKey.all RPC", () => {
|
||||
expect(llmApiKeys[0].displaySecretKey).toMatch(/^...[a-zA-Z0-9]{4}$/);
|
||||
});
|
||||
|
||||
it("should block creating an llm api key with a localhost base URL", async () => {
|
||||
await expect(
|
||||
caller.llmApiKey.create({
|
||||
projectId,
|
||||
secretKey: "test-secret",
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
baseURL: "http://localhost:11434/v1",
|
||||
}),
|
||||
).rejects.toThrow("Invalid base URL: Blocked hostname detected");
|
||||
});
|
||||
|
||||
it("should create and get an llm api key", async () => {
|
||||
const secret = "test-secret";
|
||||
const provider = "openai";
|
||||
@@ -201,7 +213,7 @@ describe("llmApiKey.all RPC", () => {
|
||||
).rejects.toThrow("User does not have access to this resource or action");
|
||||
});
|
||||
|
||||
it("should require llmApiKeys:create access for testing an existing llm api key", async () => {
|
||||
it("should require llmApiKeys:update access for testing an existing llm api key", async () => {
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider: "openai",
|
||||
@@ -261,6 +273,31 @@ describe("llmApiKey.all RPC", () => {
|
||||
expect(mockFetchLLMCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should allow testing an existing connection with an unchanged localhost base URL", async () => {
|
||||
const connection = await prisma.llmApiKeys.create({
|
||||
data: {
|
||||
projectId,
|
||||
provider: "local-ollama",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: encrypt("sk-existing"),
|
||||
displaySecretKey: "...ting",
|
||||
baseURL: "http://localhost:11434/v1",
|
||||
customModels: ["llama3.1"],
|
||||
withDefaultModels: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.llmApiKey.testUpdate({
|
||||
id: connection.id,
|
||||
projectId,
|
||||
provider: "local-ollama",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should allow testUpdate without a new secret key when the base URL is unchanged", async () => {
|
||||
const existingExtraHeaders = {
|
||||
Authorization: "Bearer stored-token",
|
||||
|
||||
@@ -354,6 +354,25 @@ describe("/api/public/llm-connections API Endpoints", () => {
|
||||
expect(response.body.extraHeaderKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("should reject creating a connection with a localhost baseURL", async () => {
|
||||
const response = await makeAPICall(
|
||||
"PUT",
|
||||
"/api/public/llm-connections",
|
||||
{
|
||||
provider: generateUniqueProvider("local-openai"),
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: "sk-local",
|
||||
baseURL: "http://localhost:11434/v1",
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(
|
||||
"Invalid baseURL: Blocked hostname detected",
|
||||
);
|
||||
});
|
||||
|
||||
it("should update existing connection (upsert)", async () => {
|
||||
const existingProvider = generateUniqueProvider("existing-provider");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createTrace,
|
||||
createSessionScore,
|
||||
getScoresByIds,
|
||||
getScoreById,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import {
|
||||
createObservationsCh,
|
||||
@@ -1304,4 +1305,121 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bearer auth (public key only)", () => {
|
||||
it("should create a score via POST /api/public/scores with Bearer public key", async () => {
|
||||
const { projectId, publicKey } = await createOrgProjectAndApiKey();
|
||||
const traceId = v4();
|
||||
const trace = createTrace({ id: traceId, project_id: projectId });
|
||||
await createTracesCh([trace]);
|
||||
|
||||
const scoreId = v4();
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
{
|
||||
id: scoreId,
|
||||
traceId,
|
||||
name: "feedback",
|
||||
value: 1,
|
||||
},
|
||||
`Bearer ${publicKey}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty("id", scoreId);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const score = await getScoreById({ projectId, scoreId });
|
||||
expect(score).toBeDefined();
|
||||
expect(score!.id).toBe(scoreId);
|
||||
expect(score!.traceId).toBe(traceId);
|
||||
expect(score!.name).toBe("feedback");
|
||||
expect(score!.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject GET /api/public/scores with Bearer public key", async () => {
|
||||
const { publicKey } = await createOrgProjectAndApiKey();
|
||||
|
||||
const response = await makeAPICall(
|
||||
"GET",
|
||||
"/api/public/scores",
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should reject GET /api/public/scores/:scoreId with Bearer public key", async () => {
|
||||
const { publicKey } = await createOrgProjectAndApiKey();
|
||||
|
||||
const response = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/scores/${v4()}`,
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should reject DELETE /api/public/scores/:scoreId with Bearer public key", async () => {
|
||||
const { publicKey } = await createOrgProjectAndApiKey();
|
||||
|
||||
const response = await makeAPICall(
|
||||
"DELETE",
|
||||
`/api/public/scores/${v4()}`,
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should reject POST /api/public/scores with invalid Bearer token", async () => {
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
{
|
||||
traceId: v4(),
|
||||
name: "feedback",
|
||||
value: 1,
|
||||
},
|
||||
`Bearer pk-invalid-key-that-does-not-exist`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should reject Bearer public key on non-scores endpoints", async () => {
|
||||
const { publicKey } = await createOrgProjectAndApiKey();
|
||||
|
||||
const [tracesRes, observationsRes, sessionsRes] = await Promise.all([
|
||||
makeAPICall(
|
||||
"GET",
|
||||
"/api/public/traces",
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
),
|
||||
makeAPICall(
|
||||
"GET",
|
||||
"/api/public/observations",
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
),
|
||||
makeAPICall(
|
||||
"GET",
|
||||
"/api/public/sessions",
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(tracesRes.status).toBe(401);
|
||||
expect(observationsRes.status).toBe(401);
|
||||
expect(sessionsRes.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { signupSchema } from "@/src/features/auth/lib/signupSchema";
|
||||
|
||||
describe("signupSchema name validation", () => {
|
||||
const validBaseInput = {
|
||||
email: "test@example.com",
|
||||
password: "P@ssw0rd!",
|
||||
};
|
||||
|
||||
it("accepts names with accented letters", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "André",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts names with hyphens", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "Smith-Jones",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts names with apostrophes", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "O'Brien",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts names with periods", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "Dr. Smith",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects names longer than 100 characters", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "a".repeat(101),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts names with smart/curly apostrophes (U+2019)", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "O\u2019Brien",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe("O'Brien");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts names with left single quotation mark (U+2018)", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "O\u2018Brien",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe("O'Brien");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects punctuation-only names", () => {
|
||||
for (const name of ["---", "...", "'''"]) {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects whitespace-only names", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: " ",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects names with disallowed punctuation", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "André!",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects names with a leading combining mark", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "\u0301André",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects names consisting only of combining marks", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "\u0301\u0302\u0303",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts NFD-decomposed names after NFC normalization", () => {
|
||||
// "é" decomposed as e + combining acute accent
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "Andre\u0301",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
// NFC normalization should merge the combining mark
|
||||
expect(result.data.name).toBe("André");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ describe("Slack Integration", () => {
|
||||
getWebClientForProject: jest.fn(),
|
||||
sendMessage: jest.fn(),
|
||||
getChannels: jest.fn(),
|
||||
getChannelInfo: jest.fn(),
|
||||
validateClient: jest.fn(),
|
||||
deleteIntegration: jest.fn(),
|
||||
};
|
||||
@@ -196,7 +197,10 @@ describe("Slack Integration", () => {
|
||||
},
|
||||
];
|
||||
|
||||
mockSlackService.getChannels.mockResolvedValue(mockChannels);
|
||||
mockSlackService.getChannels.mockResolvedValue({
|
||||
channels: mockChannels,
|
||||
hasPrivateChannelAccess: true,
|
||||
});
|
||||
|
||||
const { caller, project } = await prepare();
|
||||
|
||||
@@ -217,6 +221,7 @@ describe("Slack Integration", () => {
|
||||
|
||||
expect(result).toMatchObject({
|
||||
channels: mockChannels,
|
||||
hasPrivateChannelAccess: true,
|
||||
teamId: "T123456",
|
||||
teamName: "Test Team",
|
||||
});
|
||||
@@ -306,6 +311,71 @@ describe("Slack Integration", () => {
|
||||
expect(JSON.stringify(result)).not.toContain("xoxb-test-token");
|
||||
});
|
||||
|
||||
it("should resolve channel info for manually-typed channel names", async () => {
|
||||
const mockClient = { auth: { test: jest.fn() } };
|
||||
mockSlackService.getWebClientForProject.mockResolvedValue(mockClient);
|
||||
mockSlackService.sendMessage.mockResolvedValue({
|
||||
messageTs: "1234567890.123456",
|
||||
channel: "C999888",
|
||||
});
|
||||
mockSlackService.getChannelInfo.mockResolvedValue({
|
||||
id: "C999888",
|
||||
name: "general",
|
||||
isPrivate: false,
|
||||
});
|
||||
|
||||
const { caller, project } = await prepare();
|
||||
|
||||
await prisma.slackIntegration.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
teamId: "T123456",
|
||||
teamName: "Test Team",
|
||||
botToken: encrypt("xoxb-test-token"),
|
||||
botUserId: "U123456",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.slack.sendTestMessage({
|
||||
projectId: project.id,
|
||||
channelId: "#general",
|
||||
channelName: "general",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
channel: "C999888",
|
||||
channelInfo: {
|
||||
id: "C999888",
|
||||
name: "general",
|
||||
isPrivate: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockSlackService.getChannelInfo).toHaveBeenCalledWith(
|
||||
mockClient,
|
||||
"C999888",
|
||||
);
|
||||
|
||||
// Verify audit log records the resolved channel ID, not the #-prefixed input
|
||||
const auditLogEntry = await prisma.auditLog.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
resourceType: "slackIntegration",
|
||||
action: "create",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
expect(auditLogEntry).toBeDefined();
|
||||
const afterData = auditLogEntry?.after
|
||||
? JSON.parse(auditLogEntry.after)
|
||||
: null;
|
||||
expect(afterData).toMatchObject({
|
||||
channelId: "C999888",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create audit log entry", async () => {
|
||||
const mockClient = { auth: { test: jest.fn() } };
|
||||
mockSlackService.getWebClientForProject.mockResolvedValue(mockClient);
|
||||
@@ -501,9 +571,12 @@ describe("Slack Integration", () => {
|
||||
|
||||
it("should NEVER expose raw bot tokens in any API response", async () => {
|
||||
mockSlackService.validateClient.mockResolvedValue(true);
|
||||
mockSlackService.getChannels.mockResolvedValue([
|
||||
{ id: "C123456", name: "general", isPrivate: false, isMember: true },
|
||||
]);
|
||||
mockSlackService.getChannels.mockResolvedValue({
|
||||
channels: [
|
||||
{ id: "C123456", name: "general", isPrivate: false, isMember: true },
|
||||
],
|
||||
hasPrivateChannelAccess: true,
|
||||
});
|
||||
mockSlackService.sendMessage.mockResolvedValue({
|
||||
messageTs: "1234567890.123456",
|
||||
channel: "C123456",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.167.0";
|
||||
export const VERSION = "v3.167.1";
|
||||
|
||||
@@ -17,12 +17,21 @@ export const passwordSchema = z
|
||||
"Please choose a secure password by combining letters, numbers, and special characters.",
|
||||
});
|
||||
|
||||
export const signupSchema = z.object({
|
||||
name: StringNoHTMLNonEmpty.refine((value) => noUrlCheck(value), {
|
||||
export const nameSchema = StringNoHTMLNonEmpty.max(
|
||||
100,
|
||||
"Name must be at most 100 characters",
|
||||
)
|
||||
.transform((value) => value.normalize("NFC").replace(/[\u2018\u2019]/g, "'"))
|
||||
.refine((value) => noUrlCheck(value), {
|
||||
message: "Input should not contain a URL",
|
||||
}).refine((value) => /^[a-zA-Z0-9\s]+$/.test(value), {
|
||||
message: "Name can only contain letters, numbers, and spaces",
|
||||
}),
|
||||
})
|
||||
.refine((value) => /^\p{L}[\p{L}\p{M}\p{N}\s.'\-]*$/u.test(value), {
|
||||
message:
|
||||
"Name must start with a letter and can only contain letters, numbers, spaces, hyphens, apostrophes, and periods",
|
||||
});
|
||||
|
||||
export const signupSchema = z.object({
|
||||
name: nameSchema,
|
||||
email: z.string().email(),
|
||||
password: passwordSchema,
|
||||
referralSource: z.string().optional(),
|
||||
|
||||
@@ -31,8 +31,12 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
|
||||
disabled,
|
||||
projectId,
|
||||
}) => {
|
||||
const initialChannelId = form.getValues("slack.channelId") as string;
|
||||
const initialChannelName = form.getValues("slack.channelName") as string;
|
||||
const [selectedChannel, setSelectedChannel] = useState<SlackChannel | null>(
|
||||
null,
|
||||
initialChannelId && initialChannelName
|
||||
? { id: initialChannelId, name: initialChannelName }
|
||||
: null,
|
||||
);
|
||||
|
||||
// Get Slack integration status
|
||||
@@ -88,6 +92,7 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
|
||||
<ChannelSelector
|
||||
projectId={projectId}
|
||||
selectedChannelId={field.value}
|
||||
selectedChannel={selectedChannel}
|
||||
onChannelSelect={handleChannelSelect}
|
||||
disabled={disabled}
|
||||
placeholder="Select a channel"
|
||||
@@ -96,7 +101,12 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Select the Slack channel where notifications will be sent.
|
||||
Select the Slack channel where notifications will be sent. For
|
||||
private channels, invite the app first with{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5">
|
||||
/invite @Langfuse
|
||||
</code>{" "}
|
||||
in that channel.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -120,6 +130,23 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
buttonText="Test Channel"
|
||||
onSuccess={(channelInfo) => {
|
||||
form.setValue("slack.channelId", channelInfo.id);
|
||||
form.setValue(
|
||||
"slack.channelName",
|
||||
channelInfo.name ?? selectedChannel?.name ?? "",
|
||||
);
|
||||
setSelectedChannel((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
id: channelInfo.id,
|
||||
name: channelInfo.name ?? prev.name,
|
||||
isPrivate: channelInfo.isPrivate ?? prev.isPrivate,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Test this channel to verify the bot can send messages.
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
blockEvaluatorConfigsInTx,
|
||||
EvaluatorBlockSource,
|
||||
finalizeBlockedEvaluatorConfigBlocks,
|
||||
validateLlmConnectionBaseURL,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -130,6 +131,27 @@ async function testLLMConnection(
|
||||
}
|
||||
}
|
||||
|
||||
async function validateBaseURLForWrite(params: {
|
||||
baseURL?: string | null;
|
||||
errorPrefix?: string;
|
||||
}): Promise<void> {
|
||||
if (!params.baseURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await validateLlmConnectionBaseURL(params.baseURL);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? `${params.errorPrefix ?? "Invalid base URL"}: ${error.message}`
|
||||
: (params.errorPrefix ?? "Invalid base URL"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const llmApiKeyRouter = createTRPCRouter({
|
||||
create: protectedProjectProcedureWithoutTracing
|
||||
.input(CreateLlmApiKey)
|
||||
@@ -141,6 +163,10 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
scope: "llmApiKeys:create",
|
||||
});
|
||||
|
||||
await validateBaseURLForWrite({
|
||||
baseURL: input.baseURL,
|
||||
});
|
||||
|
||||
// Validate that default credentials sentinel is only allowed for Bedrock/VertexAI in self-hosted deployments
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
@@ -406,6 +432,17 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
scope: "llmApiKeys:create",
|
||||
});
|
||||
|
||||
if (input.baseURL) {
|
||||
try {
|
||||
await validateLlmConnectionBaseURL(input.baseURL);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Invalid base URL",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return testLLMConnection({
|
||||
adapter: input.adapter,
|
||||
provider: input.provider,
|
||||
@@ -454,6 +491,10 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (input.baseURL && isBaseURLChanged) {
|
||||
await validateLlmConnectionBaseURL(input.baseURL);
|
||||
}
|
||||
|
||||
const secretKey = hasNewSecretKey
|
||||
? (input.secretKey as string)
|
||||
: decrypt(existingKey.secretKey);
|
||||
@@ -531,6 +572,16 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
|
||||
// Validate that default credentials sentinel is only allowed for Bedrock/VertexAI in self-hosted deployments
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
const isBaseURLChanged =
|
||||
input.baseURL !== undefined
|
||||
? input.baseURL !== existingKey.baseURL
|
||||
: false;
|
||||
|
||||
if (input.baseURL && isBaseURLChanged) {
|
||||
await validateBaseURLForWrite({
|
||||
baseURL: input.baseURL,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.secretKey === BEDROCK_USE_DEFAULT_CREDENTIALS) {
|
||||
if (isLangfuseCloud || input.adapter !== LLMAdapter.Bedrock) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
redis,
|
||||
type AuthHeaderValidVerificationResult,
|
||||
type ApiAccessLevel,
|
||||
traceException,
|
||||
logger,
|
||||
} from "@langfuse/shared/src/server";
|
||||
@@ -15,6 +16,9 @@ import { contextWithLangfuseProps } from "@langfuse/shared/src/server";
|
||||
import * as opentelemetry from "@opentelemetry/api";
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
/** Access levels that can be accepted by project-scoped API routes. */
|
||||
type RouteAccessLevel = Exclude<ApiAccessLevel, "organization">;
|
||||
|
||||
type RouteConfig<
|
||||
TQuery extends ZodType<any>,
|
||||
TBody extends ZodType<any>,
|
||||
@@ -40,30 +44,41 @@ type RouteConfig<
|
||||
* @default false
|
||||
*/
|
||||
isAdminApiKeyAuthAllowed?: boolean;
|
||||
/**
|
||||
* Access levels accepted for this route. Defaults to ["project"] (Basic auth only).
|
||||
* Set to ["project", "scores"] to also allow Bearer auth with a public key
|
||||
* (which receives accessLevel "scores").
|
||||
*/
|
||||
allowedAccessLevels?: RouteAccessLevel[];
|
||||
fn: (params: {
|
||||
query: z.infer<TQuery>;
|
||||
body: z.infer<TBody>;
|
||||
req: NextApiRequest;
|
||||
res: NextApiResponse;
|
||||
auth: AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
};
|
||||
}) => Promise<z.infer<TResponse>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies regular API key authentication using ApiAuthService.
|
||||
* Verifies API key authentication (Basic or Bearer) using ApiAuthService.
|
||||
*
|
||||
* This function handles standard project API key authentication with Basic auth.
|
||||
* Returns an auth scope object with project-level access.
|
||||
* Delegates to ApiAuthService.verifyAuthHeaderAndReturnScope which handles
|
||||
* both Basic auth (public + secret key) and Bearer auth (public key only).
|
||||
* The caller controls which access levels are accepted via allowedAccessLevels.
|
||||
*
|
||||
* @param authHeader - The Authorization header from the request
|
||||
* @returns An auth scope object with project-level access
|
||||
* @param allowedAccessLevels - Access levels to accept (default: ["project"])
|
||||
* @returns An auth scope object with the verified access level
|
||||
* @throws Error with appropriate message if authentication fails
|
||||
*/
|
||||
async function verifyBasicAuth(authHeader: string | undefined): Promise<
|
||||
async function verifyApiKeyAuth(
|
||||
authHeader: string | undefined,
|
||||
allowedAccessLevels: RouteAccessLevel[] = ["project"],
|
||||
): Promise<
|
||||
AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
}
|
||||
> {
|
||||
const regularAuth = await new ApiAuthService(
|
||||
@@ -75,10 +90,14 @@ async function verifyBasicAuth(authHeader: string | undefined): Promise<
|
||||
throw { status: 401, message: regularAuth.error };
|
||||
}
|
||||
|
||||
if (regularAuth.scope.accessLevel !== "project") {
|
||||
if (
|
||||
!(allowedAccessLevels as ApiAccessLevel[]).includes(
|
||||
regularAuth.scope.accessLevel,
|
||||
)
|
||||
) {
|
||||
throw {
|
||||
status: 401,
|
||||
message: "Access denied - need to use basic auth with secret key",
|
||||
message: "Access denied - insufficient permissions for this endpoint",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,7 +110,7 @@ async function verifyBasicAuth(authHeader: string | undefined): Promise<
|
||||
}
|
||||
|
||||
return regularAuth as AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,22 +217,25 @@ async function verifyAdminApiKeyAuth(req: NextApiRequest): Promise<
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies authentication for API routes with support for both basic and admin API key auth.
|
||||
* Verifies authentication for API routes with support for both regular API key
|
||||
* auth (Basic or Bearer) and admin API key auth.
|
||||
*
|
||||
* This is the main authentication entry point that delegates to either admin or basic auth
|
||||
* based on the configuration and request headers.
|
||||
* This is the main authentication entry point that delegates to either admin
|
||||
* or regular API key auth based on the configuration and request headers.
|
||||
*
|
||||
* @param req - The Next.js API request
|
||||
* @param isAdminApiKeyAuthAllowed - Whether to allow admin API key authentication
|
||||
* @returns An auth scope object with project-level access
|
||||
* @param allowedAccessLevels - Access levels to accept for regular API key auth
|
||||
* @returns An auth scope object with the verified access level
|
||||
* @throws Error with appropriate status code if authentication fails
|
||||
*/
|
||||
export async function verifyAuth(
|
||||
req: NextApiRequest,
|
||||
isAdminApiKeyAuthAllowed: boolean,
|
||||
allowedAccessLevels: RouteAccessLevel[] = ["project"],
|
||||
): Promise<
|
||||
AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
}
|
||||
> {
|
||||
if (isAdminApiKeyAuthAllowed) {
|
||||
@@ -223,12 +245,15 @@ export async function verifyAuth(
|
||||
// Admin auth succeeded
|
||||
return adminAuth;
|
||||
}
|
||||
// Admin auth not attempted, fall back to basic auth
|
||||
return await verifyBasicAuth(req.headers.authorization);
|
||||
// Admin auth not attempted, fall back to regular API key auth
|
||||
return await verifyApiKeyAuth(
|
||||
req.headers.authorization,
|
||||
allowedAccessLevels,
|
||||
);
|
||||
}
|
||||
|
||||
// Only basic auth is allowed
|
||||
return await verifyBasicAuth(req.headers.authorization);
|
||||
// Only regular API key auth is allowed
|
||||
return await verifyApiKeyAuth(req.headers.authorization, allowedAccessLevels);
|
||||
}
|
||||
|
||||
export const createAuthedProjectAPIRoute = <
|
||||
@@ -240,14 +265,15 @@ export const createAuthedProjectAPIRoute = <
|
||||
): ((req: NextApiRequest, res: NextApiResponse) => Promise<void>) => {
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
let auth: AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
};
|
||||
|
||||
// Verify authentication (basic or admin API key)
|
||||
// Verify authentication (API key or admin API key)
|
||||
try {
|
||||
auth = await verifyAuth(
|
||||
req,
|
||||
routeConfig.isAdminApiKeyAuthAllowed || false,
|
||||
routeConfig.allowedAccessLevels || ["project"],
|
||||
);
|
||||
} catch (error: any) {
|
||||
const statusCode = error.status || 401;
|
||||
@@ -294,7 +320,7 @@ export const createAuthedProjectAPIRoute = <
|
||||
req,
|
||||
res,
|
||||
auth: auth as AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"https://staging.langfuse.com/api/public/slack/oauth"
|
||||
],
|
||||
"scopes": {
|
||||
"bot": ["channels:read", "chat:write", "chat:write.public"]
|
||||
"bot": ["channels:read", "groups:read", "chat:write", "chat:write.public"]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { RefreshCw, Search, Hash, Lock } from "lucide-react";
|
||||
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { RefreshCw, Search, Hash, Lock, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { Alert, AlertDescription } from "@/src/components/ui/alert";
|
||||
import { Select, SelectTrigger, SelectValue } from "@/src/components/ui/select";
|
||||
import {
|
||||
Command,
|
||||
@@ -11,23 +16,11 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/src/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { Alert, AlertDescription } from "@/src/components/ui/alert";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type SlackChannel } from "@langfuse/shared/src/server";
|
||||
|
||||
/**
|
||||
* Represents a Slack channel
|
||||
*/
|
||||
export interface SlackChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isMember: boolean;
|
||||
}
|
||||
export type { SlackChannel };
|
||||
|
||||
/**
|
||||
* Props for the ChannelSelector component
|
||||
@@ -37,6 +30,8 @@ interface ChannelSelectorProps {
|
||||
projectId: string;
|
||||
/** Currently selected channel ID */
|
||||
selectedChannelId?: string;
|
||||
/** Full channel object for display when the ID isn't in the fetched list (e.g. manual entry) */
|
||||
selectedChannel?: SlackChannel | null;
|
||||
/** Callback when a channel is selected */
|
||||
onChannelSelect: (channel: SlackChannel) => void;
|
||||
/** Whether the component is disabled */
|
||||
@@ -51,6 +46,8 @@ interface ChannelSelectorProps {
|
||||
showRefreshButton?: boolean;
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 32;
|
||||
|
||||
/**
|
||||
* A dropdown component for selecting Slack channels with search and filtering capabilities.
|
||||
*
|
||||
@@ -64,9 +61,11 @@ interface ChannelSelectorProps {
|
||||
*
|
||||
* The component uses a command palette style interface for better UX when dealing with
|
||||
* many channels. It supports both keyboard navigation and mouse interaction.
|
||||
* Items are virtualized with @tanstack/react-virtual to handle large channel lists (~5k).
|
||||
*
|
||||
* @param projectId - The project ID for the Slack integration
|
||||
* @param selectedChannelId - Currently selected channel ID
|
||||
* @param selectedChannel - Full channel object for display when the ID isn't in the fetched list (e.g. manual entry)
|
||||
* @param onChannelSelect - Callback when a channel is selected
|
||||
* @param disabled - Whether the component should be disabled
|
||||
* @param placeholder - Placeholder text for the selector
|
||||
@@ -77,6 +76,7 @@ interface ChannelSelectorProps {
|
||||
export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
projectId,
|
||||
selectedChannelId,
|
||||
selectedChannel: selectedChannelProp,
|
||||
onChannelSelect,
|
||||
disabled = false,
|
||||
placeholder = "Select a channel",
|
||||
@@ -87,6 +87,9 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [scrollNode, setScrollNode] = useState<HTMLDivElement | null>(null);
|
||||
const trimmedSearch = searchValue.trim();
|
||||
const effectiveName = trimmedSearch.replace(/^#/, "");
|
||||
|
||||
// Get available channels
|
||||
const {
|
||||
@@ -130,36 +133,63 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (searchValue.trim()) {
|
||||
const searchTerm = searchValue.toLowerCase().trim();
|
||||
if (effectiveName) {
|
||||
const searchTerm = effectiveName.toLowerCase();
|
||||
channels = channels.filter((channel) =>
|
||||
channel.name.toLowerCase().includes(searchTerm),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort channels: public channels first, then private, then by name
|
||||
return channels.sort((a, b) => {
|
||||
return [...channels].sort((a, b) => {
|
||||
if (a.isPrivate !== b.isPrivate) {
|
||||
return a.isPrivate ? 1 : -1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [channelsData?.channels, memberOnly, filterChannels, searchValue]);
|
||||
}, [channelsData?.channels, memberOnly, filterChannels, effectiveName]);
|
||||
|
||||
// Get selected channel info
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredChannels.length,
|
||||
getScrollElement: () => scrollNode,
|
||||
estimateSize: () => ITEM_HEIGHT,
|
||||
overscan: 20,
|
||||
});
|
||||
|
||||
// Get selected channel info — fall back to the prop for manual entries
|
||||
const selectedChannel = useMemo(() => {
|
||||
if (!selectedChannelId || !channelsData?.channels) return null;
|
||||
return channelsData.channels.find(
|
||||
if (!selectedChannelId) return null;
|
||||
const fromList = channelsData?.channels?.find(
|
||||
(channel) => channel.id === selectedChannelId,
|
||||
);
|
||||
}, [selectedChannelId, channelsData?.channels]);
|
||||
return fromList ?? selectedChannelProp ?? null;
|
||||
}, [selectedChannelId, channelsData?.channels, selectedChannelProp]);
|
||||
|
||||
// Handle channel selection
|
||||
const handleChannelSelect = (channel: SlackChannel) => {
|
||||
onChannelSelect(channel);
|
||||
setOpen(false);
|
||||
setSearchValue("");
|
||||
};
|
||||
const selectAndClose = useCallback(
|
||||
(channel: SlackChannel) => {
|
||||
onChannelSelect(channel);
|
||||
setOpen(false);
|
||||
setSearchValue("");
|
||||
},
|
||||
[onChannelSelect],
|
||||
);
|
||||
|
||||
const handleSelectByName = useCallback(() => {
|
||||
const name = searchValue.trim().replace(/^#/, "");
|
||||
if (!name) return;
|
||||
selectAndClose({
|
||||
id: `#${name}`,
|
||||
name,
|
||||
isPrivate: false,
|
||||
isMember: false,
|
||||
});
|
||||
}, [searchValue, selectAndClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollNode) {
|
||||
scrollNode.scrollTop = 0;
|
||||
}
|
||||
}, [effectiveName, scrollNode]);
|
||||
|
||||
// Render channel item
|
||||
const renderChannelItem = (channel: SlackChannel) => (
|
||||
@@ -219,10 +249,23 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const hasExactMatch = filteredChannels.some(
|
||||
(channel) => channel.name.toLowerCase() === effectiveName.toLowerCase(),
|
||||
);
|
||||
const canUseTypedName = effectiveName.length > 0 && !hasExactMatch;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
setOpen(newOpen);
|
||||
if (!newOpen) {
|
||||
setSearchValue("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -246,23 +289,51 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{searchValue
|
||||
? "No channels match your search."
|
||||
: "No channels available."}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredChannels.map((channel) => (
|
||||
<CommandList ref={setScrollNode}>
|
||||
{canUseTypedName && (
|
||||
<CommandGroup className="p-0">
|
||||
<CommandItem
|
||||
key={channel.id}
|
||||
value={channel.id}
|
||||
onSelect={() => handleChannelSelect(channel)}
|
||||
value={`use-${effectiveName}`}
|
||||
onSelect={handleSelectByName}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{renderChannelItem(channel)}
|
||||
<Hash className="text-muted-foreground h-4 w-4" />
|
||||
<span className="flex-1 truncate">
|
||||
Use "{effectiveName}"
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{!canUseTypedName && filteredChannels.length === 0 && (
|
||||
<CommandEmpty>No channels available.</CommandEmpty>
|
||||
)}
|
||||
<CommandGroup
|
||||
className="p-0"
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const channel = filteredChannels[virtualRow.index];
|
||||
return (
|
||||
<CommandItem
|
||||
key={channel.id}
|
||||
value={channel.id}
|
||||
onSelect={() => selectAndClose(channel)}
|
||||
className="cursor-pointer"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: virtualRow.start,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: ITEM_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{renderChannelItem(channel)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -290,6 +361,30 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
{memberOnly && " (member only)"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Private channel scope warning */}
|
||||
{channelsData && !channelsData.hasPrivateChannelAccess && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Private channels are not visible. To access private channels,{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium underline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/api/public/slack/install?projectId=${projectId}`,
|
||||
"slack-reauth",
|
||||
"width=600,height=700",
|
||||
)
|
||||
}
|
||||
>
|
||||
re-authenticate your Slack integration
|
||||
</button>{" "}
|
||||
to grant the required permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,8 +22,12 @@ interface SlackTestMessageButtonProps {
|
||||
size?: ButtonProps["size"];
|
||||
/** Custom button text */
|
||||
buttonText?: string;
|
||||
/** Callback when test message is sent successfully */
|
||||
onSuccess?: () => void;
|
||||
/** Callback when test message is sent successfully, receives the resolved channel info */
|
||||
onSuccess?: (channelInfo: {
|
||||
id: string;
|
||||
name?: string;
|
||||
isPrivate?: boolean;
|
||||
}) => void;
|
||||
/** Callback when test message fails */
|
||||
onError?: (error: Error) => void;
|
||||
/** Whether to show the button text */
|
||||
@@ -52,12 +56,12 @@ export const SlackTestMessageButton: React.FC<SlackTestMessageButtonProps> = ({
|
||||
}) => {
|
||||
// Test message mutation
|
||||
const testMessageMutation = api.slack.sendTestMessage.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
showSuccessToast({
|
||||
title: "Test Message Sent",
|
||||
description: "Test message sent successfully to the selected channel.",
|
||||
});
|
||||
onSuccess?.();
|
||||
onSuccess?.(data.channelInfo);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorToast("Failed to Send Test Message", error.message);
|
||||
@@ -73,7 +77,7 @@ export const SlackTestMessageButton: React.FC<SlackTestMessageButtonProps> = ({
|
||||
await testMessageMutation.mutateAsync({
|
||||
projectId,
|
||||
channelId: selectedChannel.id,
|
||||
channelName: selectedChannel.name,
|
||||
channelName: selectedChannel.name ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
// Error handling is done in the mutation
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import {
|
||||
SlackService,
|
||||
SLACK_BOT_SCOPES,
|
||||
parseSlackInstallationMetadata,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
@@ -30,7 +31,7 @@ export async function handleInstallPath(
|
||||
// 2. Set session cookies for state validation
|
||||
// 3. Render the installation page with "Add to Slack" button
|
||||
const installOptions = {
|
||||
scopes: ["channels:read", "chat:write", "chat:write.public"],
|
||||
scopes: [...SLACK_BOT_SCOPES],
|
||||
metadata: JSON.stringify({ projectId: projectId }),
|
||||
redirectUri: `${env.NEXTAUTH_URL}/api/public/slack/oauth`,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { z } from "zod";
|
||||
import { SlackService } from "@langfuse/shared/src/server";
|
||||
import { SlackService, SlackApiError } from "@langfuse/shared/src/server";
|
||||
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -112,7 +112,8 @@ export const slackRouter = createTRPCRouter({
|
||||
const client = await slackService.getWebClientForProject(
|
||||
input.projectId,
|
||||
);
|
||||
const channels = await slackService.getChannels(client);
|
||||
const { channels, hasPrivateChannelAccess } =
|
||||
await slackService.getChannels(client);
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
@@ -124,6 +125,7 @@ export const slackRouter = createTRPCRouter({
|
||||
|
||||
return {
|
||||
channels,
|
||||
hasPrivateChannelAccess,
|
||||
teamId: integration.teamId,
|
||||
teamName: integration.teamName,
|
||||
};
|
||||
@@ -201,8 +203,9 @@ export const slackRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
// Slack resolves both channel IDs (C1234) and names (#general)
|
||||
channelId: z.string(),
|
||||
channelName: z.string(),
|
||||
channelName: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -253,7 +256,7 @@ export const slackRouter = createTRPCRouter({
|
||||
},
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*Channel:*\n#${input.channelName}`,
|
||||
text: `*Channel:*\n#${input.channelName ?? input.channelId.replace(/^#/, "")}`,
|
||||
},
|
||||
{
|
||||
type: "mrkdwn",
|
||||
@@ -289,6 +292,30 @@ export const slackRouter = createTRPCRouter({
|
||||
text: "Test message from Langfuse",
|
||||
});
|
||||
|
||||
// For manually-typed channel names (id starts with #), resolve
|
||||
// channel metadata via conversations.info so the UI can show
|
||||
// accurate type/ID info. Skip for channels already selected from
|
||||
// the list since we already have their metadata.
|
||||
let channelInfo: {
|
||||
id: string;
|
||||
name?: string;
|
||||
isPrivate?: boolean;
|
||||
} = { id: result.channel };
|
||||
|
||||
if (input.channelId.startsWith("#")) {
|
||||
const resolved = await SlackService.getInstance().getChannelInfo(
|
||||
client,
|
||||
result.channel,
|
||||
);
|
||||
if (resolved) {
|
||||
channelInfo = {
|
||||
id: resolved.id,
|
||||
name: resolved.name,
|
||||
isPrivate: resolved.isPrivate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "slackIntegration",
|
||||
@@ -296,7 +323,7 @@ export const slackRouter = createTRPCRouter({
|
||||
action: "create",
|
||||
after: {
|
||||
action: "test_message_sent",
|
||||
channelId: input.channelId,
|
||||
channelId: result.channel,
|
||||
channelName: input.channelName,
|
||||
messageTs: result.messageTs,
|
||||
},
|
||||
@@ -304,7 +331,7 @@ export const slackRouter = createTRPCRouter({
|
||||
|
||||
logger.info("Test message sent successfully", {
|
||||
projectId: input.projectId,
|
||||
channelId: input.channelId,
|
||||
channelId: result.channel,
|
||||
channelName: input.channelName,
|
||||
messageTs: result.messageTs,
|
||||
});
|
||||
@@ -313,6 +340,7 @@ export const slackRouter = createTRPCRouter({
|
||||
success: true,
|
||||
messageTs: result.messageTs,
|
||||
channel: result.channel,
|
||||
channelInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to send test message", {
|
||||
@@ -321,10 +349,28 @@ export const slackRouter = createTRPCRouter({
|
||||
channelId: input.channelId,
|
||||
});
|
||||
|
||||
const slackError =
|
||||
error instanceof SlackApiError ? error.slackErrorCode : undefined;
|
||||
|
||||
const userMessage = (() => {
|
||||
switch (slackError) {
|
||||
case "channel_not_found":
|
||||
return 'Channel not found. The channel may not exist or is a private channel the bot has not been invited to. For private channels, invite the app with "/invite @Langfuse" in that channel.';
|
||||
case "not_in_channel":
|
||||
return "The bot is not a member of this channel. Please invite the bot to the channel first.";
|
||||
case "is_archived":
|
||||
return "This channel has been archived and cannot receive messages.";
|
||||
case "invalid_auth":
|
||||
case "token_revoked":
|
||||
return "Slack authentication failed. Please reconnect your Slack workspace.";
|
||||
default:
|
||||
return "Failed to send test message. Please check your Slack connection and channel permissions.";
|
||||
}
|
||||
})();
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Failed to send test message. Please check your Slack connection and channel permissions.",
|
||||
message: userMessage,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { encrypt } from "@langfuse/shared/encryption";
|
||||
import { getDisplaySecretKey } from "@/src/features/llm-api-key/server/router";
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { InvalidRequestError } from "@langfuse/shared";
|
||||
import { validateLlmConnectionBaseURL } from "@langfuse/shared/src/server";
|
||||
|
||||
export default withMiddlewares({
|
||||
GET: createAuthedProjectAPIRoute({
|
||||
@@ -85,11 +87,21 @@ export default withMiddlewares({
|
||||
provider: body.provider,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
select: { id: true, baseURL: true },
|
||||
});
|
||||
|
||||
const isUpdate = Boolean(existingConnection);
|
||||
|
||||
if (body.baseURL && body.baseURL !== existingConnection?.baseURL) {
|
||||
try {
|
||||
await validateLlmConnectionBaseURL(body.baseURL);
|
||||
} catch (error) {
|
||||
throw new InvalidRequestError(
|
||||
`Invalid baseURL: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const llmConnectionBody = {
|
||||
adapter: body.adapter,
|
||||
secretKey: encrypt(body.secretKey),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
logger,
|
||||
processEventBatch,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { ForbiddenError } from "@langfuse/shared";
|
||||
import { ScoresApiService } from "@/src/features/public-api/server/scores-api-service";
|
||||
|
||||
export default withMiddlewares({
|
||||
@@ -21,7 +22,14 @@ export default withMiddlewares({
|
||||
name: "Create Score",
|
||||
bodySchema: PostScoresBodyV1,
|
||||
responseSchema: PostScoresResponseV1,
|
||||
allowedAccessLevels: ["project", "scores"],
|
||||
fn: async ({ body, auth, res }) => {
|
||||
if (auth.scope.isIngestionSuspended) {
|
||||
throw new ForbiddenError(
|
||||
"Ingestion suspended: Usage threshold exceeded. Please upgrade your plan.",
|
||||
);
|
||||
}
|
||||
|
||||
const event = {
|
||||
id: v4(),
|
||||
type: eventTypes.SCORE_CREATE,
|
||||
|
||||
@@ -86,6 +86,9 @@ export default function SlackIntegrationSettings() {
|
||||
scope: "automations:CUD",
|
||||
});
|
||||
|
||||
// Channel was typed by name rather than selected from the list
|
||||
const isManualEntry = selectedChannel?.id.startsWith("#") ?? false;
|
||||
|
||||
return (
|
||||
<ContainerPage
|
||||
headerProps={{
|
||||
@@ -121,6 +124,7 @@ export default function SlackIntegrationSettings() {
|
||||
<ChannelSelector
|
||||
projectId={projectId}
|
||||
selectedChannelId={selectedChannel?.id}
|
||||
selectedChannel={selectedChannel}
|
||||
onChannelSelect={setSelectedChannel}
|
||||
placeholder="Choose a channel to test"
|
||||
showRefreshButton={true}
|
||||
@@ -143,15 +147,27 @@ export default function SlackIntegrationSettings() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Channel Type</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedChannel.isPrivate ? "Private" : "Public"}
|
||||
</Badge>
|
||||
{isManualEntry ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Available after sending a test message
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedChannel.isPrivate ? "Private" : "Public"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Channel ID</p>
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
{selectedChannel.id}
|
||||
</p>
|
||||
{isManualEntry ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Available after sending a test message
|
||||
</span>
|
||||
) : (
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
{selectedChannel.id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,6 +178,19 @@ export default function SlackIntegrationSettings() {
|
||||
selectedChannel={selectedChannel}
|
||||
hasAccess={hasAccess}
|
||||
disabled={false}
|
||||
onSuccess={(channelInfo) => {
|
||||
setSelectedChannel((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
id: channelInfo.id,
|
||||
name: channelInfo.name ?? prev.name,
|
||||
isPrivate:
|
||||
channelInfo.isPrivate ?? prev.isPrivate,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +199,11 @@ export default function SlackIntegrationSettings() {
|
||||
{!selectedChannel && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Select a channel above to view its details and test message
|
||||
delivery.
|
||||
delivery. For private channels, invite the app first with{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5">
|
||||
/invite @Langfuse
|
||||
</code>{" "}
|
||||
in that channel.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "worker",
|
||||
"version": "3.167.0",
|
||||
"version": "3.167.1",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -58,7 +58,7 @@
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonpath-plus": "10.3.0",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "4.18.1",
|
||||
"p-limit": "^7.3.0",
|
||||
"pg": "^8.13.0",
|
||||
"posthog-node": "^5.8.4",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
validateLlmConnectionBaseURL,
|
||||
type LlmBaseUrlValidationWhitelist,
|
||||
} from "../../../packages/shared/src/server/llm/baseUrlValidation";
|
||||
import { env } from "../../../packages/shared/src/env";
|
||||
|
||||
const originalCloudRegion = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION;
|
||||
const originalAllowedHosts = env.LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST;
|
||||
const originalAllowedIps = env.LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS;
|
||||
const originalAllowedIpSegments =
|
||||
env.LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS;
|
||||
|
||||
describe("LLM base URL validation", () => {
|
||||
afterEach(() => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = originalCloudRegion;
|
||||
(env as any).LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST =
|
||||
originalAllowedHosts;
|
||||
(env as any).LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS = originalAllowedIps;
|
||||
(env as any).LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS =
|
||||
originalAllowedIpSegments;
|
||||
});
|
||||
|
||||
it("should reject localhost by default for self-hosted instances", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("http://localhost:11434/v1"),
|
||||
).rejects.toThrow("Blocked hostname detected");
|
||||
});
|
||||
|
||||
it("should allow explicitly allowlisted localhost hosts for self-hosted instances", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
const whitelist: LlmBaseUrlValidationWhitelist = {
|
||||
hosts: ["localhost"],
|
||||
ips: [],
|
||||
ip_ranges: [],
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("http://localhost:11434/v1", whitelist),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should allow explicitly allowlisted IPv6 localhost literals for self-hosted instances", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
const whitelist: LlmBaseUrlValidationWhitelist = {
|
||||
hosts: [],
|
||||
ips: ["::1"],
|
||||
ip_ranges: [],
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("http://[::1]:11434/v1", whitelist),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should allow explicitly allowlisted IPv6 CIDR ranges for self-hosted instances", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
const whitelist: LlmBaseUrlValidationWhitelist = {
|
||||
hosts: [],
|
||||
ips: [],
|
||||
ip_ranges: ["::1/128"],
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("http://[::1]:11434/v1", whitelist),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should ignore self-host allowlists on Langfuse Cloud", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = "US";
|
||||
|
||||
const whitelist: LlmBaseUrlValidationWhitelist = {
|
||||
hosts: ["localhost"],
|
||||
ips: ["127.0.0.1"],
|
||||
ip_ranges: ["127.0.0.0/8"],
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("https://localhost/v1", whitelist),
|
||||
).rejects.toThrow("Blocked hostname detected");
|
||||
});
|
||||
|
||||
it("should allow public HTTPS URLs on Langfuse Cloud", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = "US";
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("https://1.1.1.1/v1"),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should allow unresolved public hostnames by default", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("https://gateway.invalid/v1"),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should reject non-HTTPS URLs on Langfuse Cloud", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = "US";
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("http://1.1.1.1/v1"),
|
||||
).rejects.toThrow("Only HTTPS base URLs are allowed on Langfuse Cloud");
|
||||
});
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.167.0";
|
||||
export const VERSION = "v3.167.1";
|
||||
|
||||
Reference in New Issue
Block a user