Compare commits

...
9 Commits
Author SHA1 Message Date
Tobias Wochinger 9cc69f4c67 chore: release v3.167.1 2026-04-10 10:29:22 +02:00
557f284cd1 fix(web): allow all unicode letters for signups (#12999)
* fix(web): allow unicode letters in signup name validation

* refactor(web): share name schema between signup and display name

* fix(web): enforce 100-char limit in shared name schema

* fix(web): allow hyphens, apostrophes, and periods in name validation

The nameSchema regex was too strict, rejecting common name characters
like O'Brien, Smith-Jones, and Dr. Smith. Also align the backend
updateDisplayName schema with the shared nameSchema for consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): normalize smart quotes and require letter in name validation

Normalize curly/smart apostrophes (U+2018, U+2019, U+02BC) from mobile
autocorrect to straight apostrophe before validation. Require at least
one letter to reject degenerate punctuation-only names like "---".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): require base letter not combining mark in name validation

The "must contain at least one letter" refine accepted standalone
combining marks (\p{M}) without an actual letter (\p{L}), allowing
inputs like "\u0301\u0301" to pass as valid names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): require base letter not combining mark in name validation

Add NFC normalization before validation so decomposed characters merge
into precomposed form, and add a negative lookahead (?!\p{M}) to reject
names that still start with a combining mark after normalization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): revert display name form to permissive schema and simplify nameSchema

Revert settings and userAccount display name validation back to
StringNoHTML.min(1).max(100) — the signup-oriented nameSchema is too
restrictive for existing display names containing underscores, ampersands, etc.

Simplify nameSchema: merge transforms, combine regex constraints into a single
refine that requires names start with a letter, and remove U+02BC from
smart-quote normalization (it's a linguistic letter, not a typographic quote).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:35:41 +00:00
NimarandGitHub 6702c7b50f chore(deps): bump lodash to 4.18.1 in worker (#13063)
* chore(deps): update package wait to 5days

* remove superfluous

* chore(deps): bump lodash to 4.18.1 in worker
2026-04-09 16:57:03 +00:00
25d99aa371 fix: make Slack integration more robust (#13004)
* fix: make Slack integration more robust

* refactor: deduplicate scopes

* fix(slack): make SlackChannel isPrivate and isMember optional

These fields are only known for channels from the fetched list, not for
manually-typed channel names. Making them optional avoids placeholder
booleans and fixes a type error when constructing partial channel objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: gracefully handle missing scopes

* fix(slack): cap rate-limit retry, resolve manual channel IDs, add empty state

- Cap retryAfter to 60s max to avoid gateway timeouts on large Slack values
- Add onSuccess handler in SlackActionForm to resolve #channel names to real IDs
- Show empty state message in ChannelSelector when bot has no accessible channels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(slack): resolve manual channel IDs, virtualize list, fix audit log

- Use resolved Slack channel ID in audit log instead of #-prefixed input
- Replace VirtualizedList with cmdk Command + @tanstack/react-virtual
  for keyboard navigation and DOM-efficient rendering of ~5k channels
- Move "Use typed name" fallback to separate CommandGroup so it stays
  visible when the virtualized group has zero height
- Import SlackChannel type from @langfuse/shared instead of redeclaring
- Add getChannelInfo mock and #-prefixed channelId test
- Use .concat() instead of spread for channel pagination (repo convention)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: switch to SDK retry policies

* fix(slack): strip duplicate # prefix and use functional setState

Strip leading # from channelId fallback in test message block to avoid
displaying ##general for manually-typed channel names. Use functional
setSelectedChannel form in slack.tsx to match SlackActionForm.tsx pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(slack): guard CommandEmpty on filteredChannels length

Prevent flash of "No channels available." on popover open by explicitly
guarding CommandEmpty rendering on filteredChannels.length === 0 instead
of relying on cmdk's internal item count, which is 0 on the first
render before the virtualizer scroll container mounts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: review comments

* chore: another round of review feedback

* chore: more review comments

* fix(slack): improve channel selector search

* fix comment

* fix(slack): refine channel selector search

* fix(slack): sync manifest scopes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:46:36 +00:00
NimarandGitHub e4d5f914cc chore(deps): bump next to 16.2.2 (#13068)
* chore(deps): bump next to 16.2.2

* bump big
2026-04-09 18:26:23 +02:00
Hassieb PakzadandGitHub dcb5dbf528 fix(llm-connections): validate new LLM base URLs (#13073) 2026-04-09 17:25:59 +02:00
Valery MeleshkinandGitHub d003a9c3f4 fix: limt media deletion batch size to avoid pg bind limits (#13072) 2026-04-09 16:29:25 +02:00
Valery MeleshkinandGitHub d2d56f0337 fix: allow Bearer auth on POST scores API (#13064)
fix: allow Bearer auth on POST scores API.

Addresses https://github.com/langfuse/langfuse/issues/12947
2026-04-09 13:45:29 +00:00
NimarandGitHub 6c0cf07a5a chore(deps): update package wait to 5days (#13062)
* chore(deps): update package wait to 5days

* remove superfluous
2026-04-09 12:39:11 +00:00
38 changed files with 1550 additions and 658 deletions
+5
View File
@@ -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"
+4
View File
@@ -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=
+1 -1
View File
@@ -12,7 +12,7 @@ updates:
schedule:
interval: "daily"
cooldown:
default-days: 8
default-days: 5
versioning-strategy: "increase"
commit-message:
prefix: chore
+1 -1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.167.0",
"version": "3.167.1",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -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
View File
@@ -1 +1 @@
export const VERSION = "v3.167.0";
export const VERSION = "v3.167.1";
+26
View File
@@ -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
+3 -1
View File
@@ -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 = {
+1
View File
@@ -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;
}
+18 -10
View File
@@ -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, "");
}
+314 -442
View File
File diff suppressed because it is too large Load Diff
+3 -63
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
export const VERSION = "v3.167.0";
export const VERSION = "v3.167.1";
+14 -5
View File
@@ -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 };
},
});
+1 -1
View File
@@ -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 &quot;{effectiveName}&quot;
</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`,
};
+54 -8
View File
@@ -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),
+8
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
export const VERSION = "v3.167.0";
export const VERSION = "v3.167.1";