Compare commits

...
14 Commits
Author SHA1 Message Date
steffen911 23b830074a chore: release v3.110.0
Codespell / Check for spelling errors (push) Waiting to run
CI/CD / pre-job (push) Waiting to run
CI/CD / lint (push) Blocked by required conditions
CI/CD / prettier-check (push) Blocked by required conditions
CI/CD / test-docker-build (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg12) (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg15) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / e2e-tests (push) Blocked by required conditions
CI/CD / e2e-server-tests (push) Blocked by required conditions
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2025-09-11 14:12:21 +02:00
Steffen SchmitzandGitHub fa5908a749 feat: add api endpoint to configure blob storage integration (#9047)
* feat: add api endpoint to configure blob storage integration

* chore: add test suite and generated API docs

* chore: add implementation

* chore: simplify delete endpoint

* chore: PUT route cleanups
2025-09-11 12:05:12 +00:00
Steffen SchmitzandGitHub 26045d089c perf: add a global concurrency limit to the createEvalQueue (#9077) 2025-09-11 12:09:28 +02:00
Steffen SchmitzandGitHub edfb15c010 feat: add gcp vertex tool inputs and outputs on otel spans (#9074) 2025-09-11 08:15:28 +00:00
marliessophieandGitHub 1f76ae650f fix(peek): safe parse runs url parameter in compare data hook (#9073) 2025-09-11 07:44:16 +00:00
36dc608b58 chore(worker): Move nodemon to tsx for reloads from packages/shared (#8801)
---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-09-11 00:36:02 +02:00
Jannik MaierhöferandGitHub bc0735729f fix(ui): update JS/TS SDK v4 notification (#9064) 2025-09-10 18:58:53 +00:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
88e553734e chore: avoid otel alerts about public key mismatch on worker (#9063)
* chore: avoid otel alerts about public key mismatch on worker

* Update worker/src/queues/otelIngestionQueue.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-09-10 18:58:10 +00:00
Jannik MaierhöferandGitHub bd5495321e fix(ui): remove slack support from pro tier (#9059) 2025-09-10 17:33:04 +00:00
e8ebfb8e3e feat(ui): add notification card for JS SDK v4 GA (#9022)
* feat(ui): add notification card for JS SDK v4 GA

* Update web/src/components/nav/sidebar-notifications.tsx

* Update web/src/components/nav/sidebar-notifications.tsx

* Update web/src/components/nav/sidebar-notifications.tsx

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2025-09-10 16:47:34 +00:00
Leo WeigandandGitHub 8c076b6b50 fix(tooltip): portal content (#9055) 2025-09-10 16:24:13 +00:00
Hassieb PakzadandGitHub 2cdc3cab7f fix(otel): add root span metadata to trace (#9054) 2025-09-10 16:04:37 +00:00
Leo WeigandandGitHub 729c2132af feat(sessions): add trace detail peek view and clean up layout (#9042)
* chore: add richer seed data for sessions

* session styling improvements

* support peek view in session detail

* fix formatting switch padding

* add usePreserveRelativeScroll hook

* button improvements

* move buttons and trace detail link

* columns adjustment

* finalise card layout

* prettify aggr metrics header

* tidy up paddings

* refactor to new peek view hook

* a bit more tidying up

* fix types
2025-09-10 12:44:23 +00:00
marliessophieandGitHub d3627bf225 feat(dataset-run-items): pin dataset item id column in ui table (#9040) 2025-09-10 12:27:28 +00:00
32 changed files with 2999 additions and 623 deletions
@@ -0,0 +1,119 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
imports:
commons: ./commons.yml
service:
auth: true
base-path: /api/public/integrations/blob-storage
endpoints:
getBlobStorageIntegrations:
docs: Get all blob storage integrations for the organization (requires organization-scoped API key)
method: GET
path: ""
response: BlobStorageIntegrationsResponse
upsertBlobStorageIntegration:
docs: Create or update a blob storage integration for a specific project (requires organization-scoped API key). The configuration is validated by performing a test upload to the bucket.
method: PUT
path: ""
request: CreateBlobStorageIntegrationRequest
response: BlobStorageIntegrationResponse
deleteBlobStorageIntegration:
docs: Delete a blob storage integration by ID (requires organization-scoped API key)
method: DELETE
path: "/{id}"
path-parameters:
id: string
response: BlobStorageIntegrationDeletionResponse
types:
BlobStorageIntegrationType:
enum:
- S3
- S3_COMPATIBLE
- AZURE_BLOB_STORAGE
BlobStorageIntegrationFileType:
enum:
- JSON
- CSV
- JSONL
BlobStorageExportMode:
enum:
- FULL_HISTORY
- FROM_TODAY
- FROM_CUSTOM_DATE
BlobStorageExportFrequency:
enum:
- hourly
- daily
- weekly
CreateBlobStorageIntegrationRequest:
properties:
projectId:
type: string
docs: ID of the project in which to configure the blob storage integration
type: BlobStorageIntegrationType
bucketName:
type: string
docs: Name of the storage bucket
endpoint:
type: optional<string>
docs: Custom endpoint URL (required for S3_COMPATIBLE type)
region:
type: string
docs: Storage region
accessKeyId:
type: optional<string>
docs: Access key ID for authentication
secretAccessKey:
type: optional<string>
docs: Secret access key for authentication (will be encrypted when stored)
prefix:
type: optional<string>
docs: Path prefix for exported files (must end with forward slash if provided)
exportFrequency: BlobStorageExportFrequency
enabled:
type: boolean
docs: Whether the integration is active
forcePathStyle:
type: boolean
docs: Use path-style URLs for S3 requests
fileType: BlobStorageIntegrationFileType
exportMode: BlobStorageExportMode
exportStartDate:
type: optional<datetime>
docs: Custom start date for exports (required when exportMode is FROM_CUSTOM_DATE)
BlobStorageIntegrationResponse:
properties:
id: string
projectId: string
type: BlobStorageIntegrationType
bucketName: string
endpoint: optional<string>
region: string
accessKeyId: optional<string>
prefix: string
exportFrequency: BlobStorageExportFrequency
enabled: boolean
forcePathStyle: boolean
fileType: BlobStorageIntegrationFileType
exportMode: BlobStorageExportMode
exportStartDate: optional<datetime>
nextSyncAt: optional<datetime>
lastSyncAt: optional<datetime>
createdAt: datetime
updatedAt: datetime
BlobStorageIntegrationsResponse:
properties:
data: list<BlobStorageIntegrationResponse>
BlobStorageIntegrationDeletionResponse:
properties:
message: string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.109.0",
"version": "3.110.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
-1
View File
@@ -113,7 +113,6 @@
"eslint-config-standard": "^17.1.0",
"eslint-plugin-prettier": "^5.1.3",
"kysely-codegen": "^0.16.8",
"nodemon": "^3.1.7",
"prettier": "^3.6.2",
"prisma": "^6.10.1",
"prisma-erd-generator": "^1.11.2",
@@ -110,6 +110,9 @@ async function main() {
},
});
// Realistic support chat scenario
await createSupportChatSession(project1);
await prisma.organizationMembership.upsert({
where: {
orgId_userId: {
@@ -771,6 +774,24 @@ async function createTraceSessions(project1: Project, project2: Project) {
}
}
async function createSupportChatSession(project: Project) {
const sessionId = "support-chat-session";
await prisma.traceSession.upsert({
where: {
id_projectId: {
id: sessionId,
projectId: project.id,
},
},
create: {
id: sessionId,
projectId: project.id,
environment: "default",
},
update: {},
});
}
async function generateConfigs(project: Project) {
const configNameAndId: {
name: string;
@@ -952,6 +952,310 @@ export class DataGenerator {
: JSON.stringify(this.fileContent.chatMlJson);
}
/**
* Creates realistic support chat session data with conversational flow.
* Use for: Demonstrating session-based conversations with tool calls and scoring.
*/
generateSupportChatSessionData(projectId: string): {
traces: TraceRecordInsertType[];
observations: ObservationRecordInsertType[];
scores: ScoreRecordInsertType[];
} {
// Domain: Art museum membership support (non-tech)
const dialogues = [
{
user: "Hi—my family membership at the City Art Museum was charged twice this month.",
assistant:
"Sorry about that. I can help. Could you share your membership ID and the last 4 digits of the card?",
},
{
user: "Membership ID is CAM-48291, card ending 4417.",
assistant: "Thanks. I'll check your billing records now.",
tool: {
name: "billing.lookup",
input: { membershipId: "CAM-48291", last4: "4417" },
output: {
duplicateCharge: true,
amounts: [95.0, 95.0],
date: "2025-08-28",
},
},
},
{
user: "Okay.",
assistant:
"I found a duplicate charge on 2025-08-28 for $95. I can issue a refund to the original payment method. Shall I proceed?",
},
{
user: "Yes, please issue the refund.",
assistant: "Processing your refund now. One moment.",
tool: {
name: "billing.refund",
input: { membershipId: "CAM-48291", amount: 95.0 },
output: { status: "success", refundId: "RFND-20931" },
},
},
{
user: "Thank you!",
assistant:
"Refund RFND-20931 has been issued. You'll see it on your statement within 35 business days.",
},
{
user: "No, that's all. Appreciate the quick help!",
assistant: "Happy to help. Enjoy your next visit to the museum!",
},
// a couple more lightweight turns for scrolling realism
{
user: "Oh, and do members get early access to exhibitions?",
assistant:
"Yes—members get a 48-hour early booking window and a preview evening invite.",
},
{
user: "Perfect.",
assistant: "You're all set. Have a great day!",
},
];
const now = Date.now();
const traces: TraceRecordInsertType[] = dialogues.map((d, index) => ({
id: `support-chat-${index}-${projectId.slice(-8)}`,
timestamp: now + index * 1000,
name: "SupportChatSession",
user_id: null,
metadata: { scenario: "support-chat" },
release: null,
version: null,
project_id: projectId,
environment: "default",
public: false,
bookmarked: false,
tags: ["support", "chat", "session"],
input: JSON.stringify(
d.tool
? {
messages: [
{ role: "user", content: d.user },
{ role: "assistant", content: d.assistant },
{
role: "tool",
name: d.tool.name,
content: d.tool.output,
},
],
}
: { messages: [{ role: "user", content: d.user }] },
),
output: JSON.stringify({ role: "assistant", content: d.assistant }),
session_id: "support-chat-session",
created_at: now + index * 1000,
updated_at: now + index * 1000 + 500,
event_ts: now + index * 1000,
is_deleted: 0,
}));
// Create one GENERATION observation per trace
const observations: ObservationRecordInsertType[] = dialogues
.map((d, index) => {
const start = now + index * 1000 + 50;
const end = start + 400 + Math.floor(Math.random() * 400);
const inputTokens = 80 + Math.floor(Math.random() * 60);
const outputTokens = 60 + Math.floor(Math.random() * 60);
const totalTokens = inputTokens + outputTokens;
const baseGen: ObservationRecordInsertType = {
id: `support-chat-${index}-${projectId.slice(-8)}-gen`,
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
project_id: projectId,
type: "GENERATION",
parent_observation_id: null,
environment: "default",
start_time: start,
end_time: end,
name: "llm-generation",
metadata: {},
level: "DEFAULT",
status_message: null,
version: null,
input: JSON.stringify({
messages: [
{ role: "user", content: d.user },
d.tool
? {
role: "tool",
name: d.tool.name,
content: d.tool.output,
}
: undefined,
].filter(Boolean),
}),
output: JSON.stringify({ role: "assistant", content: d.assistant }),
provided_model_name: "gpt-4o",
internal_model_id: null,
model_parameters: JSON.stringify({ temperature: 0.2 }),
provided_usage_details: {
input: inputTokens,
output: outputTokens,
total: totalTokens,
},
usage_details: {
input: inputTokens,
output: outputTokens,
total: totalTokens,
},
provided_cost_details: {
input: Math.round(inputTokens * 2) / 1_000_000,
output: Math.round(outputTokens * 3) / 1_000_000,
total: Math.round(totalTokens * 5) / 1_000_000,
},
cost_details: {
input: Math.round(inputTokens * 2) / 1_000_000,
output: Math.round(outputTokens * 3) / 1_000_000,
total: Math.round(totalTokens * 5) / 1_000_000,
},
total_cost: Math.round(totalTokens * 5) / 1_000_000,
completion_start_time: start + 120,
prompt_id: null,
prompt_name: null,
prompt_version: null,
created_at: start,
updated_at: end,
event_ts: start,
is_deleted: 0,
};
if (!d.tool) return [baseGen];
const toolObs: ObservationRecordInsertType = {
id: `support-chat-${index}-${projectId.slice(-8)}-tool`,
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
project_id: projectId,
type: "TOOL",
parent_observation_id: null,
environment: "default",
start_time: start - 40,
end_time: start - 5,
name: d.tool.name,
metadata: {},
level: "DEFAULT",
status_message: null,
version: null,
input: JSON.stringify(d.tool.input),
output: JSON.stringify(d.tool.output),
provided_model_name: null,
internal_model_id: null,
model_parameters: null,
provided_usage_details: {},
usage_details: {},
provided_cost_details: {},
cost_details: {},
total_cost: null,
completion_start_time: null,
prompt_id: null,
prompt_name: null,
prompt_version: null,
created_at: start - 40,
updated_at: start - 5,
event_ts: start - 40,
is_deleted: 0,
};
return [toolObs, baseGen];
})
.flat();
// Create a couple of scores per trace
const scores: ScoreRecordInsertType[] = dialogues
.map((_, index) => {
const baseTs = now + index * 1000 + 600;
const helpfulness: ScoreRecordInsertType = {
id: `support-chat-${index}-${projectId.slice(-8)}-score-helpfulness`,
project_id: projectId,
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
session_id: null,
dataset_run_id: null,
observation_id: null,
environment: "default",
name: "helpfulness",
value: 70 + Math.random() * 25,
source: "API",
comment: "Heuristic helpfulness score",
metadata: {},
author_user_id: null,
config_id: null,
data_type: "NUMERIC",
string_value: null,
queue_id: null,
created_at: baseTs,
updated_at: baseTs,
timestamp: baseTs,
event_ts: baseTs,
is_deleted: 0,
};
const safeVal = Math.random() > 0.1 ? 1 : 0;
const safety: ScoreRecordInsertType = {
id: `support-chat-${index}-${projectId.slice(-8)}-score-safety`,
project_id: projectId,
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
session_id: null,
dataset_run_id: null,
observation_id: null,
environment: "default",
name: "safe",
value: safeVal,
source: "API",
comment: "Content safety",
metadata: {},
author_user_id: null,
config_id: null,
data_type: "BOOLEAN",
string_value: safeVal === 1 ? "true" : "false",
queue_id: null,
created_at: baseTs + 10,
updated_at: baseTs + 10,
timestamp: baseTs + 10,
event_ts: baseTs + 10,
is_deleted: 0,
};
// Optional: resolution score on last turn
const isFinal = index === dialogues.length - 1;
const resolved: ScoreRecordInsertType | null = isFinal
? {
id: `support-chat-${index}-${projectId.slice(-8)}-score-resolved`,
project_id: projectId,
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
session_id: null,
dataset_run_id: null,
observation_id: null,
environment: "default",
name: "resolved",
value: 1,
source: "API",
comment: "Conversation resolved",
metadata: {},
author_user_id: null,
config_id: null,
data_type: "BOOLEAN",
string_value: "true",
queue_id: null,
created_at: baseTs + 20,
updated_at: baseTs + 20,
timestamp: baseTs + 20,
event_ts: baseTs + 20,
is_deleted: 0,
}
: null;
return [helpfulness, safety, resolved].filter(
Boolean,
) as ScoreRecordInsertType[];
})
.flat();
return { traces, observations, scores };
}
/**
* Creates exactly one score per evaluation trace with prefixed IDs.
* Use for: Evaluation traces that need score validation, evaluator testing.
@@ -321,6 +321,9 @@ export class SeederOrchestrator {
// Create synthetic data
await this.createSyntheticData(projectIds, opts);
// Create traces for a realistic chat session
await this.createSupportChatSessionTraces(projectIds);
// Log completion statistics (commented out to reduce terminal noise)
await this.logStatistics();
@@ -378,4 +381,27 @@ export class SeederOrchestrator {
}
}
}
async createSupportChatSessionTraces(projectIds: string[]): Promise<void> {
logger.info(
`Creating support chat session data for ${projectIds.length} projects.`,
);
for (const projectId of projectIds) {
logger.info(`Processing support chat session for project ${projectId}`);
// Generate data using the data generator
const { traces, observations, scores } =
this.dataGenerator.generateSupportChatSessionData(projectId);
try {
await this.queryBuilder.executeTracesInsert(traces);
await this.queryBuilder.executeObservationsInsert(observations);
await this.queryBuilder.executeScoresInsert(scores);
} catch (error) {
logger.error(`✗ Support chat session insert failed:`, error);
throw error;
}
}
}
}
@@ -512,6 +512,7 @@ export class OtelIngestionProcessor {
metadata: {
...resourceAttributeMetadata,
...this.extractMetadata(attributes, "trace"),
...this.extractMetadata(attributes, "observation"),
...(isLangfuseSDKSpans
? {}
: { attributes: spanAttributesInMetadata }),
@@ -1079,6 +1080,13 @@ export class OtelIngestionProcessor {
return { input, output };
}
// GCP Vertex Agent Tool call input and output
input = attributes["gcp.vertex.agent.tool_call_args"];
output = attributes["gcp.vertex.agent.tool_response"];
if (input || output) {
return { input, output };
}
// TraceLoop uses attributes property
const inputAttributes = Object.keys(attributes).filter((key) =>
key.startsWith("gen_ai.prompt"),
@@ -57,6 +57,7 @@ export const getTimeframesTracesAMT = (
): TracesAMTs => {
if (!fromTimestamp) {
// The TracesAllAMT must always be returned if there is no timestamp.
console.log("No timestamp provided, returning TracesAllAMT");
return TracesAMTs.TracesAllAMT;
}
@@ -89,6 +90,8 @@ export const getTimeframesTracesAMT = (
* @param {string} traceId - ID of the trace to check
* @param {Date} timestamp - Timestamp for time-based filtering, uses event payload or job timestamp
* @param {FilterState} filter - Filter for the trace
* @param {Date} maxTimeStamp - Upper bound on timestamp
* @param {Date} exactTimestamp - Exact match for the trace
* @returns {Promise<boolean>} - True if trace exists
*
* Notes:
+30 -354
View File
@@ -58,7 +58,7 @@ importers:
version: 15.5.2(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next-auth:
specifier: ^4.24.11
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@15.5.2(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.15)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@15.5.2(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.15)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
zod:
specifier: ^3.25.62
version: 3.25.62
@@ -238,7 +238,7 @@ importers:
version: 4.1.1
next-auth:
specifier: ^4.24.11
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@15.5.2(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.15)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@15.5.2(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.15)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
nodemailer:
specifier: ^6.9.15
version: 6.9.15
@@ -303,9 +303,6 @@ importers:
kysely-codegen:
specifier: ^0.16.8
version: 0.16.8(kysely@0.27.4)(pg@8.13.0)
nodemon:
specifier: ^3.1.7
version: 3.1.7
prettier:
specifier: ^3.6.2
version: 3.6.2
@@ -629,7 +626,7 @@ importers:
version: 15.5.2(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next-auth:
specifier: ^4.24.11
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@15.5.2(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.15)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@15.5.2(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.15)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next-query-params:
specifier: ^5.1.0
version: 5.1.0(next@15.5.2(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)(use-query-params@2.2.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))
@@ -1022,9 +1019,6 @@ importers:
msw:
specifier: ^2.6.5
version: 2.6.5(@types/node@24.3.0)(typescript@5.9.2)
nodemon:
specifier: ^3.1.7
version: 3.1.7
prettier:
specifier: ^3.6.2
version: 3.6.2
@@ -1034,12 +1028,15 @@ importers:
tsc-watch:
specifier: ^6.2.0
version: 6.2.0(typescript@5.9.2)
tsx:
specifier: ^4.20.5
version: 4.20.5
typescript:
specifier: ^5.7.2
version: 5.9.2
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(jsdom@20.0.3)(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(jsdom@20.0.3)(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1)
wait-for-expect:
specifier: ^3.0.2
version: 3.0.2
@@ -1967,204 +1964,102 @@ packages:
'@emotion/weak-memoize@0.4.0':
resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
'@esbuild/aix-ppc64@0.23.1':
resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.25.7':
resolution: {integrity: sha512-uD0kKFHh6ETr8TqEtaAcV+dn/2qnYbH/+8wGEdY70Qf7l1l/jmBUbrmQqwiPKAQE6cOQ7dTj6Xr0HzQDGHyceQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.23.1':
resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.25.7':
resolution: {integrity: sha512-p0ohDnwyIbAtztHTNUTzN5EGD/HJLs1bwysrOPgSdlIA6NDnReoVfoCyxG6W1d85jr2X80Uq5KHftyYgaK9LPQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.23.1':
resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.25.7':
resolution: {integrity: sha512-Jhuet0g1k9rAJHrXGIh7sFknFuT4sfytYZpZpuZl7YKDhnPByVAm5oy2LEBmMbuYf3ejWVYCc2seX81Mk+madA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.23.1':
resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.25.7':
resolution: {integrity: sha512-mMxIJFlSgVK23HSsII3ZX9T2xKrBCDGyk0qiZnIW10LLFFtZLkFD6imZHu7gUo2wkNZwS9Yj3mOtZD3ZPcjCcw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.23.1':
resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.25.7':
resolution: {integrity: sha512-jyOFLGP2WwRwxM8F1VpP6gcdIJc8jq2CUrURbbTouJoRO7XCkU8GdnTDFIHdcifVBT45cJlOYsZ1kSlfbKjYUQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.23.1':
resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.25.7':
resolution: {integrity: sha512-m9bVWqZCwQ1BthruifvG64hG03zzz9gE2r/vYAhztBna1/+qXiHyP9WgnyZqHgGeXoimJPhAmxfbeU+nMng6ZA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.23.1':
resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.25.7':
resolution: {integrity: sha512-Bss7P4r6uhr3kDzRjPNEnTm/oIBdTPRNQuwaEFWT/uvt6A1YzK/yn5kcx5ZxZ9swOga7LqeYlu7bDIpDoS01bA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.23.1':
resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.7':
resolution: {integrity: sha512-S3BFyjW81LXG7Vqmr37ddbThrm3A84yE7ey/ERBlK9dIiaWgrjRlre3pbG7txh1Uaxz8N7wGGQXmC9zV+LIpBQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.23.1':
resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.25.7':
resolution: {integrity: sha512-HfQZQqrNOfS1Okn7PcsGUqHymL1cWGBslf78dGvtrj8q7cN3FkapFgNA4l/a5lXDwr7BqP2BSO6mz9UremNPbg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.23.1':
resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.25.7':
resolution: {integrity: sha512-JZMIci/1m5vfQuhKoFXogCKVYVfYQmoZJg8vSIMR4TUXbF+0aNlfXH3DGFEFMElT8hOTUF5hisdZhnrZO/bkDw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.23.1':
resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.25.7':
resolution: {integrity: sha512-9Jex4uVpdeofiDxnwHRgen+j6398JlX4/6SCbbEFEXN7oMO2p0ueLN+e+9DdsdPLUdqns607HmzEFnxwr7+5wQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.23.1':
resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.25.7':
resolution: {integrity: sha512-TG1KJqjBlN9IHQjKVUYDB0/mUGgokfhhatlay8aZ/MSORMubEvj/J1CL8YGY4EBcln4z7rKFbsH+HeAv0d471w==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.23.1':
resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.25.7':
resolution: {integrity: sha512-Ty9Hj/lx7ikTnhOfaP7ipEm/ICcBv94i/6/WDg0OZ3BPBHhChsUbQancoWYSO0WNkEiSW5Do4febTTy4x1qYQQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.23.1':
resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.25.7':
resolution: {integrity: sha512-MrOjirGQWGReJl3BNQ58BLhUBPpWABnKrnq8Q/vZWWwAB1wuLXOIxS2JQ1LT3+5T+3jfPh0tyf5CpbyQHqnWIQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.23.1':
resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.25.7':
resolution: {integrity: sha512-9pr23/pqzyqIZEZmQXnFyqp3vpa+KBk5TotfkzGMqpw089PGm0AIowkUppHB9derQzqniGn3wVXgck19+oqiOw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.23.1':
resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.25.7':
resolution: {integrity: sha512-4dP11UVGh9O6Y47m8YvW8eoA3r8qL2toVZUbBKyGta8j6zdw1cn9F/Rt59/Mhv0OgY68pHIMjGXWOUaykCnx+w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.23.1':
resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.25.7':
resolution: {integrity: sha512-ghJMAJTdw/0uhz7e7YnpdX1xVn7VqA0GrWrAO2qKMuqbvgHT2VZiBv1BQ//VcHsPir4wsL3P2oPggfKPzTKoCA==}
engines: {node: '>=18'}
@@ -2177,36 +2072,18 @@ packages:
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.23.1':
resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.7':
resolution: {integrity: sha512-tUZRvLtgLE5OyN46sPSYlgmHoBS5bx2URSrgZdW1L1teWPYVmXh+QN/sKDqkzBo/IHGcKcHLKDhBeVVkO7teEA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.23.1':
resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-arm64@0.25.7':
resolution: {integrity: sha512-bTJ50aoC+WDlDGBReWYiObpYvQfMjBNlKztqoNUL0iUkYtwLkBQQeEsTq/I1KyjsKA5tyov6VZaPb8UdD6ci6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.23.1':
resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.7':
resolution: {integrity: sha512-TA9XfJrgzAipFUU895jd9j2SyDh9bbNkK2I0gHcvqb/o84UeQkBpi/XmYX3cO1q/9hZokdcDqQxIi6uLVrikxg==}
engines: {node: '>=18'}
@@ -2219,48 +2096,24 @@ packages:
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.23.1':
resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.25.7':
resolution: {integrity: sha512-umkbn7KTxsexhv2vuuJmj9kggd4AEtL32KodkJgfhNOHMPtQ55RexsaSrMb+0+jp9XL4I4o2y91PZauVN4cH3A==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.23.1':
resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.25.7':
resolution: {integrity: sha512-j20JQGP/gz8QDgzl5No5Gr4F6hurAZvtkFxAKhiv2X49yi/ih8ECK4Y35YnjlMogSKJk931iNMcd35BtZ4ghfw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.23.1':
resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.25.7':
resolution: {integrity: sha512-4qZ6NUfoiiKZfLAXRsvFkA0hoWVM+1y2bSHXHkpdLAs/+r0LgwqYohmfZCi985c6JWHhiXP30mgZawn/XrqAkQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.23.1':
resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.25.7':
resolution: {integrity: sha512-FaPsAHTwm+1Gfvn37Eg3E5HIpfR3i6x1AIcla/MkqAIupD4BW3MrSeUqfoTzwwJhk3WE2/KqUn4/eenEJC76VA==}
engines: {node: '>=18'}
@@ -6009,9 +5862,6 @@ packages:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@@ -7552,11 +7402,6 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
esbuild@0.23.1:
resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==}
engines: {node: '>=18'}
hasBin: true
esbuild@0.25.7:
resolution: {integrity: sha512-daJB0q2dmTzo90L9NjRaohhRWrCzYxWNFTjEi72/h+p5DcY3yn4MacWfDakHmaBaDzDiuLJsCh0+6LK/iX+c+Q==}
engines: {node: '>=18'}
@@ -8286,9 +8131,6 @@ packages:
get-tsconfig@4.10.1:
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
get-tsconfig@4.8.1:
resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==}
get-uri@6.0.5:
resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==}
engines: {node: '>= 14'}
@@ -8546,9 +8388,6 @@ packages:
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore-by-default@1.0.1:
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
ignore-walk@5.0.1:
resolution: {integrity: sha512-yemi4pMf51WKT7khInJqAvsIGzoqYXblnsz0ql8tM+yi1EKYTY1evX4NAbJrLL/Aanr2HyZeluqU+Oi7MGHokw==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -10185,15 +10024,6 @@ packages:
resolution: {integrity: sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==}
engines: {node: '>=6.0.0'}
nodemon@3.1.7:
resolution: {integrity: sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==}
engines: {node: '>=10'}
hasBin: true
nopt@1.0.10:
resolution: {integrity: sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==}
hasBin: true
normalize-package-data@2.5.0:
resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
@@ -10924,9 +10754,6 @@ packages:
psl@1.9.0:
resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
pump@3.0.0:
resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
@@ -11543,10 +11370,6 @@ packages:
simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
simple-update-notifier@2.0.0:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'}
simple-wcswidth@1.0.1:
resolution: {integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==}
@@ -12058,10 +11881,6 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
touch@3.1.0:
resolution: {integrity: sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==}
hasBin: true
tough-cookie@4.1.4:
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
engines: {node: '>=6'}
@@ -12142,8 +11961,8 @@ packages:
peerDependencies:
typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta'
tsx@4.19.1:
resolution: {integrity: sha512-0flMz1lh74BR4wOvBjuh9olbnwqCPc35OOlfyzHba0Dc+QNUeWX/Gq2YTbnwcWPO3BMd8fkzRVrHcsR+a7z7rA==}
tsx@4.20.5:
resolution: {integrity: sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -12290,9 +12109,6 @@ packages:
unbzip2-stream@1.4.3:
resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==}
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
@@ -14756,153 +14572,81 @@ snapshots:
'@emotion/weak-memoize@0.4.0': {}
'@esbuild/aix-ppc64@0.23.1':
optional: true
'@esbuild/aix-ppc64@0.25.7':
optional: true
'@esbuild/android-arm64@0.23.1':
optional: true
'@esbuild/android-arm64@0.25.7':
optional: true
'@esbuild/android-arm@0.23.1':
optional: true
'@esbuild/android-arm@0.25.7':
optional: true
'@esbuild/android-x64@0.23.1':
optional: true
'@esbuild/android-x64@0.25.7':
optional: true
'@esbuild/darwin-arm64@0.23.1':
optional: true
'@esbuild/darwin-arm64@0.25.7':
optional: true
'@esbuild/darwin-x64@0.23.1':
optional: true
'@esbuild/darwin-x64@0.25.7':
optional: true
'@esbuild/freebsd-arm64@0.23.1':
optional: true
'@esbuild/freebsd-arm64@0.25.7':
optional: true
'@esbuild/freebsd-x64@0.23.1':
optional: true
'@esbuild/freebsd-x64@0.25.7':
optional: true
'@esbuild/linux-arm64@0.23.1':
optional: true
'@esbuild/linux-arm64@0.25.7':
optional: true
'@esbuild/linux-arm@0.23.1':
optional: true
'@esbuild/linux-arm@0.25.7':
optional: true
'@esbuild/linux-ia32@0.23.1':
optional: true
'@esbuild/linux-ia32@0.25.7':
optional: true
'@esbuild/linux-loong64@0.23.1':
optional: true
'@esbuild/linux-loong64@0.25.7':
optional: true
'@esbuild/linux-mips64el@0.23.1':
optional: true
'@esbuild/linux-mips64el@0.25.7':
optional: true
'@esbuild/linux-ppc64@0.23.1':
optional: true
'@esbuild/linux-ppc64@0.25.7':
optional: true
'@esbuild/linux-riscv64@0.23.1':
optional: true
'@esbuild/linux-riscv64@0.25.7':
optional: true
'@esbuild/linux-s390x@0.23.1':
optional: true
'@esbuild/linux-s390x@0.25.7':
optional: true
'@esbuild/linux-x64@0.23.1':
optional: true
'@esbuild/linux-x64@0.25.7':
optional: true
'@esbuild/netbsd-arm64@0.25.7':
optional: true
'@esbuild/netbsd-x64@0.23.1':
optional: true
'@esbuild/netbsd-x64@0.25.7':
optional: true
'@esbuild/openbsd-arm64@0.23.1':
optional: true
'@esbuild/openbsd-arm64@0.25.7':
optional: true
'@esbuild/openbsd-x64@0.23.1':
optional: true
'@esbuild/openbsd-x64@0.25.7':
optional: true
'@esbuild/openharmony-arm64@0.25.7':
optional: true
'@esbuild/sunos-x64@0.23.1':
optional: true
'@esbuild/sunos-x64@0.25.7':
optional: true
'@esbuild/win32-arm64@0.23.1':
optional: true
'@esbuild/win32-arm64@0.25.7':
optional: true
'@esbuild/win32-ia32@0.23.1':
optional: true
'@esbuild/win32-ia32@0.25.7':
optional: true
'@esbuild/win32-x64@0.23.1':
optional: true
'@esbuild/win32-x64@0.25.7':
optional: true
@@ -15844,7 +15588,7 @@ snapshots:
'@next-auth/prisma-adapter@1.0.7(@prisma/client@6.10.1(prisma@6.10.1(typescript@5.9.2))(typescript@5.9.2))(next-auth@4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@15.5.2(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.15)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))':
dependencies:
'@prisma/client': 6.10.1(prisma@6.10.1(typescript@5.9.2))(typescript@5.9.2)
next-auth: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@15.5.2(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.15)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next-auth: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@15.5.2(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.15)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@next/bundle-analyzer@15.5.2':
dependencies:
@@ -18633,7 +18377,7 @@ snapshots:
'@jest/globals': 29.7.0
'@types/jest': 29.5.12
jest: 29.7.0(@types/node@24.3.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.9.2))
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(jsdom@20.0.3)(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(jsdom@20.0.3)(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1)
'@testing-library/react@15.0.7(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
@@ -19344,14 +19088,14 @@ snapshots:
chai: 5.2.1
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(vite@7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1))':
'@vitest/mocker@3.2.4(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(vite@7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
msw: 2.6.5(@types/node@24.3.0)(typescript@5.9.2)
vite: 7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1)
vite: 7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -19514,8 +19258,6 @@ snapshots:
abab@2.0.6: {}
abbrev@1.1.1: {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
@@ -20893,11 +20635,9 @@ snapshots:
dependencies:
ms: 2.1.2
debug@4.3.7(supports-color@5.5.0):
debug@4.3.7:
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 5.5.0
debug@4.4.1:
dependencies:
@@ -21353,34 +21093,6 @@ snapshots:
is-date-object: 1.0.5
is-symbol: 1.0.4
esbuild@0.23.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.23.1
'@esbuild/android-arm': 0.23.1
'@esbuild/android-arm64': 0.23.1
'@esbuild/android-x64': 0.23.1
'@esbuild/darwin-arm64': 0.23.1
'@esbuild/darwin-x64': 0.23.1
'@esbuild/freebsd-arm64': 0.23.1
'@esbuild/freebsd-x64': 0.23.1
'@esbuild/linux-arm': 0.23.1
'@esbuild/linux-arm64': 0.23.1
'@esbuild/linux-ia32': 0.23.1
'@esbuild/linux-loong64': 0.23.1
'@esbuild/linux-mips64el': 0.23.1
'@esbuild/linux-ppc64': 0.23.1
'@esbuild/linux-riscv64': 0.23.1
'@esbuild/linux-s390x': 0.23.1
'@esbuild/linux-x64': 0.23.1
'@esbuild/netbsd-x64': 0.23.1
'@esbuild/openbsd-arm64': 0.23.1
'@esbuild/openbsd-x64': 0.23.1
'@esbuild/sunos-x64': 0.23.1
'@esbuild/win32-arm64': 0.23.1
'@esbuild/win32-ia32': 0.23.1
'@esbuild/win32-x64': 0.23.1
optional: true
esbuild@0.25.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.7
@@ -21510,7 +21222,7 @@ snapshots:
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
fast-glob: 3.3.3
get-tsconfig: 4.8.1
get-tsconfig: 4.10.1
is-core-module: 2.15.1
is-glob: 4.0.3
transitivePeerDependencies:
@@ -21527,7 +21239,7 @@ snapshots:
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
fast-glob: 3.3.3
get-tsconfig: 4.8.1
get-tsconfig: 4.10.1
is-core-module: 2.15.1
is-glob: 4.0.3
transitivePeerDependencies:
@@ -21544,7 +21256,7 @@ snapshots:
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.9.2))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
fast-glob: 3.3.3
get-tsconfig: 4.8.1
get-tsconfig: 4.10.1
is-core-module: 2.15.1
is-glob: 4.0.3
transitivePeerDependencies:
@@ -21853,7 +21565,7 @@ snapshots:
eslint: 8.57.0
optionalDependencies:
'@typescript-eslint/eslint-plugin': 7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.9.2))(eslint@8.57.0)(typescript@5.9.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(jsdom@20.0.3)(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(jsdom@20.0.3)(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1)
transitivePeerDependencies:
- supports-color
- typescript
@@ -22375,10 +22087,6 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
get-tsconfig@4.8.1:
dependencies:
resolve-pkg-maps: 1.0.0
get-uri@6.0.5:
dependencies:
basic-ftp: 5.0.5
@@ -22693,7 +22401,7 @@ snapshots:
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.3
debug: 4.3.7(supports-color@5.5.0)
debug: 4.3.7
transitivePeerDependencies:
- supports-color
@@ -22715,8 +22423,6 @@ snapshots:
ieee754@1.2.1: {}
ignore-by-default@1.0.1: {}
ignore-walk@5.0.1:
dependencies:
minimatch: 5.1.6
@@ -24658,7 +24364,7 @@ snapshots:
dependencies:
type-fest: 2.19.0
next-auth@4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@15.5.2(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.15)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
next-auth@4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@15.5.2(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.15)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
'@babel/runtime': 7.26.0
'@panva/hkdf': 1.1.1
@@ -24768,23 +24474,6 @@ snapshots:
nodemailer@6.9.15: {}
nodemon@3.1.7:
dependencies:
chokidar: 3.6.0
debug: 4.3.7(supports-color@5.5.0)
ignore-by-default: 1.0.1
minimatch: 3.1.2
pstree.remy: 1.1.8
semver: 7.6.3
simple-update-notifier: 2.0.0
supports-color: 5.5.0
touch: 3.1.0
undefsafe: 2.0.5
nopt@1.0.10:
dependencies:
abbrev: 1.1.1
normalize-package-data@2.5.0:
dependencies:
hosted-git-info: 2.8.9
@@ -25534,8 +25223,6 @@ snapshots:
psl@1.9.0: {}
pstree.remy@1.1.8: {}
pump@3.0.0:
dependencies:
end-of-stream: 1.4.5
@@ -26352,10 +26039,6 @@ snapshots:
dependencies:
is-arrayish: 0.3.2
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.1
simple-wcswidth@1.0.1: {}
sirv@2.0.4:
@@ -26921,10 +26604,6 @@ snapshots:
totalist@3.0.1: {}
touch@3.1.0:
dependencies:
nopt: 1.0.10
tough-cookie@4.1.4:
dependencies:
psl: 1.9.0
@@ -27004,13 +26683,12 @@ snapshots:
tslib: 1.14.1
typescript: 5.9.2
tsx@4.19.1:
tsx@4.20.5:
dependencies:
esbuild: 0.23.1
esbuild: 0.25.7
get-tsconfig: 4.10.1
optionalDependencies:
fsevents: 2.3.3
optional: true
ttl-set@1.0.0:
dependencies:
@@ -27169,8 +26847,6 @@ snapshots:
buffer: 5.7.1
through: 2.3.8
undefsafe@2.0.5: {}
undici-types@5.26.5: {}
undici-types@7.10.0: {}
@@ -27393,13 +27069,13 @@ snapshots:
'@egjs/hammerjs': 2.0.17
component-emitter: 1.3.1
vite-node@3.2.4(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1):
vite-node@3.2.4(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1):
dependencies:
cac: 6.7.14
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1)
vite: 7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -27414,7 +27090,7 @@ snapshots:
- tsx
- yaml
vite@7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1):
vite@7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1):
dependencies:
esbuild: 0.25.7
fdir: 6.4.6(picomatch@4.0.3)
@@ -27427,14 +27103,14 @@ snapshots:
fsevents: 2.3.3
jiti: 2.5.1
terser: 5.44.0
tsx: 4.19.1
tsx: 4.20.5
yaml: 2.5.1
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(jsdom@20.0.3)(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(jsdom@20.0.3)(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(vite@7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1))
'@vitest/mocker': 3.2.4(msw@2.6.5(@types/node@24.3.0)(typescript@5.9.2))(vite@7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -27452,8 +27128,8 @@ snapshots:
tinyglobby: 0.2.14
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1)
vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.19.1)(yaml@2.5.1)
vite: 7.0.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1)
vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.5.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
+36 -10
View File
@@ -1,15 +1,26 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [".env"],
"globalDependencies": [
".env"
],
"envMode": "loose",
"tasks": {
"build": {
"dependsOn": ["db:generate", "^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"dependsOn": [
"db:generate",
"^build"
],
"outputs": [
"dist/**",
".next/**",
"!.next/cache/**"
],
"cache": true
},
"start": {
"dependsOn": ["^start"]
"dependsOn": [
"^start"
]
},
"db:migrate": {
"cache": false
@@ -22,29 +33,44 @@
"dev": {
"cache": false,
"persistent": true,
"dependsOn": ["db:generate"]
"dependsOn": [
"db:generate"
]
},
"dev:worker": {
"cache": false,
"persistent": true,
"dependsOn": ["db:generate"]
"dependsOn": [
"db:generate",
]
},
"dev:web": {
"cache": false,
"persistent": true,
"dependsOn": ["db:generate"]
"dependsOn": [
"db:generate"
]
},
"db:generate": {
"cache": false,
"dependsOn": ["^db:generate"]
"dependsOn": [
"^db:generate"
]
},
"lint": {
"cache": true,
"outputs": []
},
"test": {
"dependsOn": ["^test", "db:generate"],
"dependsOn": [
"^test",
"db:generate"
],
"cache": true
},
"worker#dev": {
"cache": false,
"persistent": true
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.109.0",
"version": "3.110.0",
"private": true,
"license": "MIT",
"engines": {
+318 -4
View File
@@ -560,6 +560,146 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/AnnotationQueueAssignmentRequest'
/api/public/integrations/blob-storage:
get:
description: >-
Get all blob storage integrations for the organization (requires
organization-scoped API key)
operationId: blobStorageIntegrations_getBlobStorageIntegrations
tags:
- BlobStorageIntegrations
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/BlobStorageIntegrationsResponse'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
put:
description: >-
Create or update a blob storage integration for a specific project
(requires organization-scoped API key). The configuration is validated
by performing a test upload to the bucket.
operationId: blobStorageIntegrations_upsertBlobStorageIntegration
tags:
- BlobStorageIntegrations
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/BlobStorageIntegrationResponse'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateBlobStorageIntegrationRequest'
/api/public/integrations/blob-storage/{id}:
delete:
description: >-
Delete a blob storage integration by ID (requires organization-scoped
API key)
operationId: blobStorageIntegrations_deleteBlobStorageIntegration
tags:
- BlobStorageIntegrations
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/BlobStorageIntegrationDeletionResponse'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
/api/public/comments:
post:
description: >-
@@ -1440,7 +1580,7 @@ paths:
Notes:
- Introduction to data model:
https://langfuse.com/docs/tracing-data-model
https://langfuse.com/docs/observability/data-model
- Batch sizes are limited to 3.5 MB in total. You need to adjust the
number of events per batch accordingly.
@@ -4413,9 +4553,11 @@ paths:
in: query
description: >-
Comma-separated list of fields to include in the response. Available
field groups are 'core' (always included), 'io' (input, output,
metadata), 'scores', 'observations', 'metrics'. If not provided, all
fields are included. Example: 'core,scores,metrics'
field groups: 'core' (always included), 'io' (input, output,
metadata), 'scores', 'observations', 'metrics'. If not specified,
all fields are returned. Example: 'core,scores,metrics'. Note:
Excluded 'observations' or 'scores' fields return empty arrays;
excluded 'metrics' returns -1 for 'totalCost' and 'latency'.
required: false
schema:
type: string
@@ -4686,6 +4828,178 @@ components:
- userId
- queueId
- projectId
BlobStorageIntegrationType:
title: BlobStorageIntegrationType
type: string
enum:
- S3
- S3_COMPATIBLE
- AZURE_BLOB_STORAGE
BlobStorageIntegrationFileType:
title: BlobStorageIntegrationFileType
type: string
enum:
- JSON
- CSV
- JSONL
BlobStorageExportMode:
title: BlobStorageExportMode
type: string
enum:
- FULL_HISTORY
- FROM_TODAY
- FROM_CUSTOM_DATE
BlobStorageExportFrequency:
title: BlobStorageExportFrequency
type: string
enum:
- hourly
- daily
- weekly
CreateBlobStorageIntegrationRequest:
title: CreateBlobStorageIntegrationRequest
type: object
properties:
projectId:
type: string
description: ID of the project in which to configure the blob storage integration
type:
$ref: '#/components/schemas/BlobStorageIntegrationType'
bucketName:
type: string
description: Name of the storage bucket
endpoint:
type: string
nullable: true
description: Custom endpoint URL (required for S3_COMPATIBLE type)
region:
type: string
description: Storage region
accessKeyId:
type: string
nullable: true
description: Access key ID for authentication
secretAccessKey:
type: string
nullable: true
description: Secret access key for authentication (will be encrypted when stored)
prefix:
type: string
nullable: true
description: >-
Path prefix for exported files (must end with forward slash if
provided)
exportFrequency:
$ref: '#/components/schemas/BlobStorageExportFrequency'
enabled:
type: boolean
description: Whether the integration is active
forcePathStyle:
type: boolean
description: Use path-style URLs for S3 requests
fileType:
$ref: '#/components/schemas/BlobStorageIntegrationFileType'
exportMode:
$ref: '#/components/schemas/BlobStorageExportMode'
exportStartDate:
type: string
format: date-time
nullable: true
description: >-
Custom start date for exports (required when exportMode is
FROM_CUSTOM_DATE)
required:
- projectId
- type
- bucketName
- region
- exportFrequency
- enabled
- forcePathStyle
- fileType
- exportMode
BlobStorageIntegrationResponse:
title: BlobStorageIntegrationResponse
type: object
properties:
id:
type: string
projectId:
type: string
type:
$ref: '#/components/schemas/BlobStorageIntegrationType'
bucketName:
type: string
endpoint:
type: string
nullable: true
region:
type: string
accessKeyId:
type: string
nullable: true
prefix:
type: string
exportFrequency:
$ref: '#/components/schemas/BlobStorageExportFrequency'
enabled:
type: boolean
forcePathStyle:
type: boolean
fileType:
$ref: '#/components/schemas/BlobStorageIntegrationFileType'
exportMode:
$ref: '#/components/schemas/BlobStorageExportMode'
exportStartDate:
type: string
format: date-time
nullable: true
nextSyncAt:
type: string
format: date-time
nullable: true
lastSyncAt:
type: string
format: date-time
nullable: true
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
required:
- id
- projectId
- type
- bucketName
- region
- prefix
- exportFrequency
- enabled
- forcePathStyle
- fileType
- exportMode
- createdAt
- updatedAt
BlobStorageIntegrationsResponse:
title: BlobStorageIntegrationsResponse
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/BlobStorageIntegrationResponse'
required:
- data
BlobStorageIntegrationDeletionResponse:
title: BlobStorageIntegrationDeletionResponse
type: object
properties:
message:
type: string
required:
- message
CreateCommentRequest:
title: CreateCommentRequest
type: object
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,619 @@
/** @jest-environment node */
import {
makeZodVerifiedAPICall,
makeAPICall,
} from "@/src/__tests__/test-utils";
import { prisma } from "@langfuse/shared/src/db";
import { z } from "zod/v4";
import { randomUUID } from "crypto";
import {
createAndAddApiKeysToDb,
createBasicAuthHeader,
} from "@langfuse/shared/src/server";
// Schemas based on Fern schema definition
const BlobStorageIntegrationResponseSchema = z.object({
id: z.string(),
projectId: z.string(),
type: z.enum(["S3", "S3_COMPATIBLE", "AZURE_BLOB_STORAGE"]),
bucketName: z.string(),
endpoint: z.string().nullable(),
region: z.string(),
accessKeyId: z.string().nullable(),
prefix: z.string(),
exportFrequency: z.enum(["hourly", "daily", "weekly"]),
enabled: z.boolean(),
forcePathStyle: z.boolean(),
fileType: z.enum(["JSON", "CSV", "JSONL"]),
exportMode: z.enum(["FULL_HISTORY", "FROM_TODAY", "FROM_CUSTOM_DATE"]),
exportStartDate: z.coerce.date().nullable(),
nextSyncAt: z.coerce.date().nullable(),
lastSyncAt: z.coerce.date().nullable(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
const BlobStorageIntegrationsResponseSchema = z.object({
data: z.array(BlobStorageIntegrationResponseSchema),
});
const BlobStorageIntegrationDeletionResponseSchema = z.object({
message: z.string(),
});
// Valid blob storage integration request payload
const validBlobStorageConfig = {
projectId: "",
type: "S3" as const,
bucketName: "test-bucket",
endpoint: null,
region: "us-east-1",
accessKeyId: "AKIA123456789",
secretAccessKey: "secret123456789",
prefix: "langfuse-exports/",
exportFrequency: "daily" as const,
enabled: true,
forcePathStyle: false,
fileType: "JSONL" as const,
exportMode: "FULL_HISTORY" as const,
exportStartDate: null,
};
describe("Blob Storage Integrations API", () => {
// Test data
let testOrgId: string;
let testProject1Id: string;
let testProject2Id: string;
let testApiKey: string;
let testApiSecretKey: string;
let otherOrgId: string;
let otherProjectId: string;
beforeAll(async () => {
// Create test organization
const testOrg = await prisma.organization.create({
data: {
name: `Blob Storage Test Org ${randomUUID().substring(0, 8)}`,
cloudConfig: { plan: "Team" },
},
});
testOrgId = testOrg.id;
// Create test projects
const testProject1 = await prisma.project.create({
data: {
name: `Blob Storage Test Project 1 ${randomUUID().substring(0, 8)}`,
orgId: testOrgId,
},
});
testProject1Id = testProject1.id;
const testProject2 = await prisma.project.create({
data: {
name: `Blob Storage Test Project 2 ${randomUUID().substring(0, 8)}`,
orgId: testOrgId,
},
});
testProject2Id = testProject2.id;
// Create organization API key
const orgApiKey = await createAndAddApiKeysToDb({
prisma,
entityId: testOrgId,
scope: "ORGANIZATION",
note: "Test API Key for Blob Storage API",
predefinedKeys: {
publicKey: `pk-lf-blob-${randomUUID().substring(0, 8)}`,
secretKey: `sk-lf-blob-${randomUUID().substring(0, 8)}`,
},
});
testApiKey = orgApiKey.publicKey;
testApiSecretKey = orgApiKey.secretKey;
// Create another organization for cross-org tests
const otherOrg = await prisma.organization.create({
data: {
name: `Other Blob Storage Org ${randomUUID().substring(0, 8)}`,
cloudConfig: { plan: "Team" },
},
});
otherOrgId = otherOrg.id;
const otherProject = await prisma.project.create({
data: {
name: `Other Blob Storage Project ${randomUUID().substring(0, 8)}`,
orgId: otherOrgId,
},
});
otherProjectId = otherProject.id;
});
afterAll(async () => {
// Clean up test data
await prisma.organization.delete({
where: { id: testOrgId },
});
await prisma.organization.delete({
where: { id: otherOrgId },
});
});
describe("GET /api/public/integrations/blob-storage", () => {
let testIntegrationId: string;
beforeAll(async () => {
// Create a test blob storage integration
const integration = await prisma.blobStorageIntegration.create({
data: {
projectId: testProject1Id,
type: "S3",
bucketName: "test-bucket",
region: "us-east-1",
accessKeyId: "test-access-key",
secretAccessKey: "encrypted-secret",
prefix: "langfuse-exports/",
exportFrequency: "daily",
enabled: true,
forcePathStyle: false,
fileType: "JSONL",
exportMode: "FULL_HISTORY",
},
});
testIntegrationId = integration.projectId;
});
afterAll(async () => {
// Clean up test integration
await prisma.blobStorageIntegration.deleteMany({
where: { projectId: testProject1Id },
});
});
it("should get all blob storage integrations for the organization", async () => {
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationsResponseSchema,
"GET",
"/api/public/integrations/blob-storage",
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBe(1);
const integration = response.body.data.find(
(i) => i.id === testIntegrationId,
);
expect(integration).toBeDefined();
expect(integration?.projectId).toBe(testProject1Id);
expect(integration?.type).toBe("S3");
expect(integration?.bucketName).toBe("test-bucket");
expect(integration?.accessKeyId).toBe("test-access-key");
// Verify that secretAccessKey is not returned
expect(integration).not.toHaveProperty("secretAccessKey");
});
it("should return 401 with invalid API key", async () => {
const result = await makeAPICall(
"GET",
"/api/public/integrations/blob-storage",
undefined,
createBasicAuthHeader("invalid-key", "invalid-secret"),
);
expect(result.status).toBe(401);
});
it("should return 403 with project-scoped API key", async () => {
// Create project API key
const projectApiKey = await createAndAddApiKeysToDb({
prisma,
entityId: testProject1Id,
scope: "PROJECT",
note: "Project API Key",
predefinedKeys: {
publicKey: `pk-lf-proj-${randomUUID().substring(0, 8)}`,
secretKey: `sk-lf-proj-${randomUUID().substring(0, 8)}`,
},
});
const result = await makeAPICall(
"GET",
"/api/public/integrations/blob-storage",
undefined,
createBasicAuthHeader(projectApiKey.publicKey, projectApiKey.secretKey),
);
expect(result.status).toBe(403);
expect(result.body.message).toContain(
"Organization-scoped API key required",
);
// Clean up
await prisma.apiKey.delete({ where: { id: projectApiKey.id } });
});
});
describe("PUT /api/public/integrations/blob-storage", () => {
afterEach(async () => {
// Clean up any created integrations
await prisma.blobStorageIntegration.deleteMany({
where: {
projectId: {
in: [testProject1Id, testProject2Id],
},
},
});
});
it("should create a new blob storage integration", async () => {
const requestBody = {
...validBlobStorageConfig,
projectId: testProject1Id,
};
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationResponseSchema,
"PUT",
"/api/public/integrations/blob-storage",
requestBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(response.status).toBe(200);
expect(response.body.projectId).toBe(testProject1Id);
expect(response.body.type).toBe("S3");
expect(response.body.bucketName).toBe("test-bucket");
expect(response.body.enabled).toBe(true);
// Verify secretAccessKey is not returned
expect(response.body).not.toHaveProperty("secretAccessKey");
// Verify it was saved to database
const savedIntegration = await prisma.blobStorageIntegration.findUnique({
where: { projectId: testProject1Id },
});
expect(savedIntegration).toBeDefined();
expect(savedIntegration?.bucketName).toBe("test-bucket");
});
it("should update an existing blob storage integration", async () => {
// Create initial integration
await prisma.blobStorageIntegration.create({
data: {
projectId: testProject1Id,
type: "S3",
bucketName: "old-bucket",
region: "us-west-1",
accessKeyId: "old-key",
secretAccessKey: "old-secret",
prefix: "old-prefix/",
exportFrequency: "hourly",
enabled: false,
forcePathStyle: true,
fileType: "JSON",
exportMode: "FROM_TODAY",
},
});
const updateBody = {
...validBlobStorageConfig,
projectId: testProject1Id,
bucketName: "updated-bucket",
enabled: true,
exportFrequency: "weekly" as const,
};
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationResponseSchema,
"PUT",
"/api/public/integrations/blob-storage",
updateBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
expect(response.body.bucketName).toBe("updated-bucket");
expect(response.body.enabled).toBe(true);
expect(response.body.exportFrequency).toBe("weekly");
});
it("should validate required fields", async () => {
const invalidBody = {
projectId: testProject1Id,
type: "S3",
// Missing bucketName
region: "us-east-1",
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
invalidBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(400);
expect(result.body.message).toContain("Invalid request data");
});
it("should validate enum values", async () => {
const invalidBody = {
...validBlobStorageConfig,
projectId: testProject1Id,
type: "INVALID_TYPE",
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
invalidBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(400);
expect(result.body.message).toContain("Invalid request data");
});
it("should validate prefix format", async () => {
const invalidBody = {
...validBlobStorageConfig,
projectId: testProject1Id,
prefix: "invalid-prefix", // Should end with /
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
invalidBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(400);
expect(result.body.error).toBeDefined();
});
it("should return 404 for non-existent project", async () => {
const nonExistentProjectId = randomUUID();
const requestBody = {
...validBlobStorageConfig,
projectId: nonExistentProjectId,
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
requestBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(404);
expect(result.body.message).toContain("Project not found");
});
it("should return 404 for project from different organization", async () => {
const requestBody = {
...validBlobStorageConfig,
projectId: otherProjectId,
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
requestBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(404);
expect(result.body.message).toContain("Project not found");
});
it("should return 401 with invalid API key", async () => {
const requestBody = {
...validBlobStorageConfig,
projectId: testProject1Id,
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
requestBody,
createBasicAuthHeader("invalid-key", "invalid-secret"),
);
expect(result.status).toBe(401);
});
it("should handle different blob storage types", async () => {
const azureConfig = {
...validBlobStorageConfig,
projectId: testProject1Id,
type: "AZURE_BLOB_STORAGE" as const,
endpoint: "https://myaccount.blob.core.windows.net",
};
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationResponseSchema,
"PUT",
"/api/public/integrations/blob-storage",
azureConfig,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
expect(response.body.type).toBe("AZURE_BLOB_STORAGE");
expect(response.body.endpoint).toBe(
"https://myaccount.blob.core.windows.net",
);
});
it("should handle export modes with dates", async () => {
const customDateConfig = {
...validBlobStorageConfig,
projectId: testProject1Id,
exportMode: "FROM_CUSTOM_DATE" as const,
exportStartDate: "2024-01-01T00:00:00Z",
};
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationResponseSchema,
"PUT",
"/api/public/integrations/blob-storage",
customDateConfig,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
expect(response.body.exportMode).toBe("FROM_CUSTOM_DATE");
expect(response.body.exportStartDate).toBeDefined();
});
});
describe("DELETE /api/public/integrations/blob-storage/{id}", () => {
let testIntegrationId: string;
beforeEach(async () => {
// Create test integration
const integration = await prisma.blobStorageIntegration.create({
data: {
projectId: testProject1Id,
type: "S3",
bucketName: "test-bucket",
region: "us-east-1",
accessKeyId: "test-key",
secretAccessKey: "test-secret",
prefix: "test/",
exportFrequency: "daily",
enabled: true,
forcePathStyle: false,
fileType: "JSONL",
exportMode: "FULL_HISTORY",
},
});
testIntegrationId = integration.projectId; // Based on current implementation, ID is projectId
});
afterEach(async () => {
// Clean up
await prisma.blobStorageIntegration.deleteMany({
where: { projectId: testProject1Id },
});
});
it("should delete a blob storage integration", async () => {
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationDeletionResponseSchema,
"DELETE",
`/api/public/integrations/blob-storage/${testIntegrationId}`,
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
expect(response.body.message).toBeDefined();
// Verify it was deleted from database
const deletedIntegration = await prisma.blobStorageIntegration.findUnique(
{
where: { projectId: testIntegrationId },
},
);
expect(deletedIntegration).toBeNull();
});
it("should return 404 for non-existent integration", async () => {
const nonExistentId = randomUUID();
const result = await makeAPICall(
"DELETE",
`/api/public/integrations/blob-storage/${nonExistentId}`,
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(404);
});
it("should return 404 for integration from different organization", async () => {
// Create integration in other org
const otherOrgIntegration = await prisma.blobStorageIntegration.create({
data: {
projectId: otherProjectId,
type: "S3",
bucketName: "other-bucket",
region: "us-east-1",
accessKeyId: "other-key",
secretAccessKey: "other-secret",
prefix: "other/",
exportFrequency: "daily",
enabled: true,
forcePathStyle: false,
fileType: "JSONL",
exportMode: "FULL_HISTORY",
},
});
const result = await makeAPICall(
"DELETE",
`/api/public/integrations/blob-storage/${otherOrgIntegration.projectId}`,
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(404);
// Check config still exists
const integrations = await prisma.blobStorageIntegration.findMany({
where: { projectId: otherProjectId },
});
expect(integrations).toHaveLength(1);
// Clean up
await prisma.blobStorageIntegration.delete({
where: { projectId: otherProjectId },
});
});
it("should return 401 with invalid API key", async () => {
const result = await makeAPICall(
"DELETE",
`/api/public/integrations/blob-storage/${testIntegrationId}`,
undefined,
createBasicAuthHeader("invalid-key", "invalid-secret"),
);
expect(result.status).toBe(401);
});
it("should return 403 with project-scoped API key", async () => {
const projectApiKey = await createAndAddApiKeysToDb({
prisma,
entityId: testProject1Id,
scope: "PROJECT",
note: "Project API Key",
predefinedKeys: {
publicKey: `pk-lf-proj-del-${randomUUID().substring(0, 8)}`,
secretKey: `sk-lf-proj-del-${randomUUID().substring(0, 8)}`,
},
});
const result = await makeAPICall(
"DELETE",
`/api/public/integrations/blob-storage/${testIntegrationId}`,
undefined,
createBasicAuthHeader(projectApiKey.publicKey, projectApiKey.secretKey),
);
expect(result.status).toBe(403);
// Clean up
await prisma.apiKey.delete({ where: { id: projectApiKey.id } });
});
});
describe("Method validation", () => {
it("should return 405 for unsupported methods", async () => {
const result = await makeAPICall(
"PATCH",
"/api/public/integrations/blob-storage",
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(405);
expect(result.body.message).toContain("Method not allowed");
});
});
});
@@ -492,6 +492,7 @@ describe("OTel Resource Span Mapping", () => {
environment: "production",
});
});
it("should throw an error if langfuse scope spans have wrong project ID", async () => {
const langfuseOtelSpans = [
{
@@ -2019,6 +2020,30 @@ describe("OTel Resource Span Mapping", () => {
entityAttributeValue: '{"foo": "bar"}',
},
],
[
"should map gcp.vertex.agent.tool_call_args to input",
{
entity: "observation",
otelAttributeKey: "gcp.vertex.agent.tool_call_args",
otelAttributeValue: {
stringValue: '{"foo": "bar"}',
},
entityAttributeKey: "input",
entityAttributeValue: '{"foo": "bar"}',
},
],
[
"should map gcp.vertex.agent.tool_response to output",
{
entity: "observation",
otelAttributeKey: "gcp.vertex.agent.tool_response",
otelAttributeValue: {
stringValue: '{"foo": "bar"}',
},
entityAttributeKey: "output",
entityAttributeValue: '{"foo": "bar"}',
},
],
])(
"Attributes: %s",
async (
@@ -29,6 +29,15 @@ type SidebarNotification = {
};
const notifications: SidebarNotification[] = [
{
id: "js-sdk-v4",
title: "New JS/TS SDK v4",
description:
"With v4, the TypeScript SDK significantly improves DX, speed, and ecosystem integrations.",
link: "https://langfuse.com/docs/observability/sdk/typescript/overview",
linkTitle: "Learn more",
createdAt: "2025-09-09",
},
{
id: "python-sdk-v3",
title: "New Python SDK v3",
+186 -83
View File
@@ -28,6 +28,11 @@ import { ScrollArea } from "@/src/components/ui/scroll-area";
import { Label } from "@/src/components/ui/label";
import { AnnotationQueueObjectType, type APIScoreV2 } from "@langfuse/shared";
import { CreateNewAnnotationQueueItem } from "@/src/features/annotation-queues/components/CreateNewAnnotationQueueItem";
import { TablePeekView } from "@/src/components/table/peek";
import { PeekViewTraceDetail } from "@/src/components/table/peek/peek-trace-detail";
import { usePeekNavigation } from "@/src/components/table/peek/hooks/usePeekNavigation";
import { NewDatasetItemFromExistingObject } from "@/src/features/datasets/components/NewDatasetItemFromExistingObject";
import { ItemBadge } from "@/src/components/ItemBadge";
// some projects have thousands of traces in a sessions, paginate to avoid rendering all at once
const PAGE_SIZE = 50;
@@ -153,11 +158,27 @@ export const SessionPage: React.FC<{
},
},
);
const { openPeek, closePeek, resolveDetailNavigationPath, expandPeek } =
usePeekNavigation({
expandConfig: {
// Expand peeked traces to the trace detail route; sessions list traces
basePath: `/project/${projectId}/traces`,
},
queryParams: ["timestamp"],
extractParamsValuesFromRow: (row: any) => ({
timestamp: row.timestamp.toISOString(),
}),
});
useEffect(() => {
if (session.isSuccess) {
setDetailPageList(
"traces",
session.data.traces.map((t) => ({ id: t.id })),
session.data.traces.map((t) => ({
id: t.id,
params: { timestamp: t.timestamp.toISOString() },
})),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -202,8 +223,6 @@ export const SessionPage: React.FC<{
return (
<Page
withPadding
scrollable
headerProps={{
title: sessionId,
itemType: "SESSION",
@@ -280,90 +299,136 @@ export const SessionPage: React.FC<{
),
}}
>
<div className="flex flex-wrap items-end gap-2">
<SessionUsers projectId={projectId} users={session.data?.users} />
<Badge variant="outline">Traces: {session.data?.traces.length}</Badge>
{session.data && (
<div className="flex h-full flex-col overflow-auto">
<div className="sticky top-0 z-40 flex flex-wrap gap-2 border-b bg-background p-4">
{session.data?.users?.length ? (
<SessionUsers projectId={projectId} users={session.data.users} />
) : null}
<Badge variant="outline">
Total cost: {usdFormatter(session.data.totalCost, 2)}
Total traces: {session.data?.traces.length}
</Badge>
)}
<SessionScores
scores={
session.data?.scores?.map((score) => ({
...score,
timestamp: new Date(score.timestamp),
createdAt: new Date(score.createdAt),
updatedAt: new Date(score.updatedAt),
})) ?? []
}
/>
</div>
<div className="mt-5 flex flex-col gap-2 border-t pt-5">
{session.data?.traces.slice(0, visibleTraces).map((trace) => (
<Card
className="group grid gap-3 border-border p-2 shadow-none hover:border-ring md:grid-cols-3"
key={trace.id}
>
<div className="col-span-2 overflow-hidden">
<SessionIO
traceId={trace.id}
projectId={projectId}
timestamp={new Date(trace.timestamp)}
/>
</div>
<div className="-mt-1 p-1 opacity-50 transition-opacity group-hover:opacity-100">
<Link
href={`/project/${projectId}/traces/${trace.id}`}
className="text-xs hover:underline"
{session.data && (
<Badge variant="outline">
Total cost: {usdFormatter(session.data.totalCost, 2)}
</Badge>
)}
<SessionScores
scores={
session.data?.scores?.map((score) => ({
...score,
timestamp: new Date(score.timestamp),
createdAt: new Date(score.createdAt),
updatedAt: new Date(score.updatedAt),
})) ?? []
}
/>
</div>
<div className="flex flex-col gap-4 p-4">
{session.data?.traces.slice(0, visibleTraces).map((trace) => (
<Card className="border-border shadow-none" key={trace.id}>
<div className="grid md:grid-cols-[1fr_1px_358px] lg:grid-cols-[1fr_1px_28rem]">
<div className="overflow-hidden py-4 pl-4 pr-4">
<SessionIO
traceId={trace.id}
projectId={projectId}
timestamp={new Date(trace.timestamp)}
/>
</div>
<div className="hidden bg-border md:block"></div>
<div className="flex flex-col border-t py-4 pl-4 pr-4 md:border-0">
<div className="mb-4 flex flex-col gap-2">
<Link
href={`/project/${projectId}/traces/${trace.id}`}
className="flex items-start gap-2 rounded-lg border p-2 transition-colors hover:bg-accent"
onClick={(e) => {
// Only prevent default for normal clicks, allow modifier key clicks through
if (!e.metaKey && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
openPeek(trace.id, trace);
}
}}
>
<ItemBadge type="TRACE" isSmall />
<div className="flex flex-col">
<span className="text-xs font-medium">
{trace.name} ({trace.id})&nbsp;
</span>
<span className="text-xs text-muted-foreground">
{trace.timestamp.toLocaleString()}
</span>
</div>
</Link>
<div className="flex flex-wrap gap-2">
<NewDatasetItemFromTraceId
projectId={projectId}
traceId={trace.id}
timestamp={new Date(trace.timestamp)}
buttonVariant="outline"
/>
<AnnotateDrawer
projectId={projectId}
scoreTarget={{
type: "trace",
traceId: trace.id,
}}
scores={trace.scores}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
variant="button"
buttonVariant="outline"
analyticsData={{
type: "trace",
source: "SessionDetail",
}}
key={"annotation-drawer" + trace.id}
environment={trace.environment}
/>
<CommentDrawerButton
projectId={projectId}
variant="outline"
objectId={trace.id}
objectType="TRACE"
count={getNumberFromMap(
traceCommentCounts.data,
trace.id,
)}
/>
</div>
</div>
<div className="flex-1">
<p className="mb-1 font-medium">Scores</p>
<div className="flex flex-wrap content-start items-start gap-1">
<GroupedScoreBadges scores={trace.scores} />
</div>
</div>
</div>
</div>
</Card>
))}
{session.data?.traces &&
session.data.traces.length > visibleTraces && (
<Button
onClick={() => setVisibleTraces((prev) => prev + PAGE_SIZE)}
variant="ghost"
className="self-center"
>
Trace: {trace.name} ({trace.id})&nbsp;
</Link>
<div className="text-xs text-muted-foreground">
{trace.timestamp.toLocaleString()}
</div>
<div className="mb-1 mt-2 text-xs text-muted-foreground">
Scores
</div>
<div className="mb-1 flex flex-wrap content-start items-start gap-1">
<GroupedScoreBadges scores={trace.scores} />
</div>
<div className="flex items-center gap-1">
<AnnotateDrawer
projectId={projectId}
scoreTarget={{
type: "trace",
traceId: trace.id,
}}
scores={trace.scores}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
variant="badge"
analyticsData={{ type: "trace", source: "SessionDetail" }}
key={"annotation-drawer" + trace.id}
environment={trace.environment}
/>
<CommentDrawerButton
projectId={projectId}
objectId={trace.id}
objectType="TRACE"
count={getNumberFromMap(traceCommentCounts.data, trace.id)}
className="h-6 rounded-full text-xs"
/>
</div>
</div>
</Card>
))}
{session.data?.traces && session.data.traces.length > visibleTraces && (
<Button
onClick={() => setVisibleTraces((prev) => prev + PAGE_SIZE)}
variant="ghost"
className="self-center"
>
{`Load ${Math.min(session.data.traces.length - visibleTraces, PAGE_SIZE)} More`}
</Button>
)}
{`Load ${Math.min(session.data.traces.length - visibleTraces, PAGE_SIZE)} More`}
</Button>
)}
</div>
</div>
<TablePeekView
peekView={{
itemType: "TRACE",
detailNavigationKey: "traces",
openPeek,
closePeek,
expandPeek,
resolveDetailNavigationPath,
children: <PeekViewTraceDetail projectId={projectId} />,
tableDataUpdatedAt: session.dataUpdatedAt,
}}
/>
</Page>
);
};
@@ -411,3 +476,41 @@ export const SessionIO = ({
</div>
);
};
const NewDatasetItemFromTraceId = (props: {
projectId: string;
traceId: string;
timestamp: Date;
buttonVariant?: "outline" | "secondary";
}) => {
// SessionIO already fetches the trace, so this doesn't add an extra request
const trace = api.traces.byId.useQuery(
{
traceId: props.traceId,
projectId: props.projectId,
timestamp: props.timestamp,
},
{
enabled: typeof props.traceId === "string",
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false,
},
);
if (!trace.data) return null;
return (
<NewDatasetItemFromExistingObject
projectId={props.projectId}
traceId={props.traceId}
input={trace.data.input ?? null}
output={trace.data.output ?? null}
metadata={trace.data.metadata ?? null}
buttonVariant={props.buttonVariant}
/>
);
};
@@ -6,7 +6,15 @@ type UsePeekRunsCompareDataProps = {
datasetItemId?: string;
traceId?: string;
timestamp?: Date;
runs?: string[];
runs?: string[] | string;
};
// Ensure runs is always an array - handle case where URL param comes as string
const safeParseUrlParamToArray = (
runs?: string[] | string,
): string[] | undefined => {
if (!runs) return undefined;
return Array.isArray(runs) ? runs : [runs];
};
export const usePeekRunsCompareData = ({
@@ -17,6 +25,8 @@ export const usePeekRunsCompareData = ({
datasetItemId,
runs,
}: UsePeekRunsCompareDataProps) => {
const parsedRuns = safeParseUrlParamToArray(runs);
const trace = api.traces.byIdWithObservationsAndScores.useQuery(
{
traceId: traceId as string,
@@ -49,10 +59,11 @@ export const usePeekRunsCompareData = ({
projectId,
datasetId: datasetId as string,
datasetItemId: datasetItemId as string,
datasetRunIds: runs as string[] | undefined,
datasetRunIds: parsedRuns,
},
{
enabled: !!datasetId && !!datasetItemId && !!runs && runs.length > 0,
enabled:
!!datasetId && !!datasetItemId && !!parsedRuns && parsedRuns.length > 0,
},
);
+6 -1
View File
@@ -16,6 +16,7 @@ import { SubHeaderLabel } from "@/src/components/layouts/header";
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import useLocalStorage from "@/src/components/useLocalStorage";
import usePreserveRelativeScroll from "@/src/hooks/usePreserveRelativeScroll";
export const IOPreview: React.FC<{
input?: Prisma.JsonValue;
@@ -55,6 +56,8 @@ export const IOPreview: React.FC<{
const capture = usePostHogClientCapture();
const input = deepParseJson(props.input);
const output = deepParseJson(props.output);
const [compensateScrollRef, startPreserveScroll] =
usePreserveRelativeScroll<HTMLDivElement>([selectedView]);
// parse old completions: { completion: string } -> string
const outLegacyCompletionSchema = z
@@ -118,14 +121,16 @@ export const IOPreview: React.FC<{
{isPrettyViewAvailable && !currentView ? (
<div className="flex w-full flex-row justify-start">
<Tabs
ref={compensateScrollRef}
className="h-fit py-0.5"
value={selectedView}
onValueChange={(value) => {
startPreserveScroll();
capture("trace_detail:io_mode_switch", { view: value });
setLocalCurrentView(value as "pretty" | "json");
}}
>
<TabsList className="h-fit py-0.5">
<TabsList className="h-fit p-0.5">
<TabsTrigger value="pretty" className="h-fit px-1 text-xs">
Formatted
</TabsTrigger>
+11 -9
View File
@@ -15,15 +15,17 @@ const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-[9999] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-[9999] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.109.0";
export const VERSION = "v3.110.0";
@@ -56,7 +56,6 @@ export const stripeProducts: StripeProduct[] = [
"Unlimited annotation queues",
"High rate limits",
"SOC2, ISO27001 reports",
"Support via Slack",
],
},
},
@@ -76,6 +75,7 @@ export const stripeProducts: StripeProduct[] = [
"SSO enforcement",
"Fine-grained RBAC",
"Data retention management",
"Support via Slack",
],
},
},
@@ -99,6 +99,7 @@ export function DatasetRunItemsByRunTable(props: {
header: "Dataset Item",
id: "datasetItemId",
size: 110,
isPinnedLeft: true,
cell: ({ row }) => {
const datasetItemId: string = row.getValue("datasetItemId");
return (
@@ -43,6 +43,7 @@ export const NewDatasetItemFromExistingObject = (props: {
output: string | null;
metadata: Prisma.JsonValue;
isCopyItem?: boolean;
buttonVariant?: "outline" | "secondary";
}) => {
const parsedInput =
props.input && typeof props.input === "string"
@@ -74,6 +75,7 @@ export const NewDatasetItemFromExistingObject = (props: {
scope: "datasets:CUD",
});
const capture = usePostHogClientCapture();
const buttonVariant = props.buttonVariant || "secondary";
return (
<>
@@ -137,7 +139,7 @@ export const NewDatasetItemFromExistingObject = (props: {
object: props.observationId ? "observation" : "trace",
});
}}
variant="secondary"
variant={buttonVariant}
disabled={!hasAccess}
>
{hasAccess ? (
@@ -0,0 +1,86 @@
import { z } from "zod/v4";
/**
* Enums
*/
export const BlobStorageIntegrationType = z.enum([
"S3",
"S3_COMPATIBLE",
"AZURE_BLOB_STORAGE",
]);
export const BlobStorageIntegrationFileType = z.enum(["JSON", "CSV", "JSONL"]);
export const BlobStorageExportMode = z.enum([
"FULL_HISTORY",
"FROM_TODAY",
"FROM_CUSTOM_DATE",
]);
/**
* Request/Response Types
*/
export const CreateBlobStorageIntegrationRequest = z
.object({
projectId: z.string(),
type: BlobStorageIntegrationType,
bucketName: z.string(),
endpoint: z.string().nullable().optional(),
region: z.string(),
accessKeyId: z.string().nullable().optional(),
secretAccessKey: z.string().nullable().optional(),
prefix: z
.string()
.optional()
.default("")
.refine(
(value) => value === "" || value.endsWith("/"),
"Prefix must be empty or end with a forward slash",
),
exportFrequency: z.string(),
enabled: z.boolean(),
forcePathStyle: z.boolean(),
fileType: BlobStorageIntegrationFileType,
exportMode: BlobStorageExportMode,
exportStartDate: z.coerce.date().nullable().optional(),
})
.strict()
.refine(
(data) => {
return !(data.exportMode === "FROM_CUSTOM_DATE" && !data.exportStartDate);
},
{
message:
"exportStartDate is required when exportMode is FROM_CUSTOM_DATE",
path: ["exportStartDate"],
},
);
export const BlobStorageIntegrationResponse = z
.object({
id: z.string(),
projectId: z.string(),
type: BlobStorageIntegrationType,
bucketName: z.string(),
endpoint: z.string().nullable(),
region: z.string(),
accessKeyId: z.string().nullable(),
prefix: z.string(),
exportFrequency: z.string(),
enabled: z.boolean(),
forcePathStyle: z.boolean(),
fileType: BlobStorageIntegrationFileType,
exportMode: BlobStorageExportMode,
exportStartDate: z.coerce.date().nullable(),
nextSyncAt: z.coerce.date().nullable(),
lastSyncAt: z.coerce.date().nullable(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
})
.strict();
export type BlobStorageIntegrationResponseType = z.infer<
typeof BlobStorageIntegrationResponse
>;
+171
View File
@@ -0,0 +1,171 @@
import { useCallback, useLayoutEffect, useRef } from "react";
type ScrollTarget = Window | Element;
function isWindow(target: ScrollTarget): target is Window {
return (
(target as Window).scrollBy !== undefined &&
(target as Window).document !== undefined
);
}
function getComputedOverflowY(node: Element): string {
const style = window.getComputedStyle(node);
return style.overflowY;
}
function isScrollable(node: Element): boolean {
const overflowY = getComputedOverflowY(node);
if (overflowY !== "auto" && overflowY !== "scroll") return false;
return node.scrollHeight > node.clientHeight;
}
function findNearestScrollContainer(start: Element): ScrollTarget {
let node: Element | null = start;
while (node && node !== document.body) {
if (isScrollable(node)) return node;
node = node.parentElement;
}
return window;
}
export interface UsePreserveRelativeScrollOptions {
getScrollTarget?: (clickedElement: Element) => ScrollTarget;
enabled?: boolean;
}
/**
* Preserves the referenced element's relative scroll position when content size changes.
*
* @param options - Optional configuration for scroll target detection and enabling the behavior.
* @param layoutDeps - Values that change when the layout will reflow due to your interaction
* (for example, the selected tab value). Provide stable, memoized values; avoid passing
* freshly created objects or inline functions.
*/
export function usePreserveRelativeScroll<T extends Element = Element>(
layoutDeps: ReadonlyArray<unknown> = [],
options?: UsePreserveRelativeScrollOptions,
): [React.RefObject<T | null>, () => void] {
const enabled = options?.enabled ?? true;
const beforeTopRef = useRef<number | null>(null);
const targetRef = useRef<ScrollTarget | null>(null);
const didUserScrollRef = useRef<boolean>(false);
const elementRef = useRef<T | null>(null);
const compensatedRef = useRef<boolean>(false);
const attachScrollListener = useCallback(() => {
const target = targetRef.current;
const cancel = () => {
didUserScrollRef.current = true;
};
const keydownHandler = (e: KeyboardEvent) => {
const keys = [
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
" ",
];
if (keys.includes(e.key)) cancel();
};
window.addEventListener("wheel", cancel, { passive: true, once: true });
window.addEventListener("touchmove", cancel, { passive: true, once: true });
window.addEventListener(
"keydown",
keydownHandler as EventListener,
{
once: true,
} as AddEventListenerOptions,
);
if (target && !isWindow(target)) {
target.addEventListener(
"wheel",
cancel as EventListener,
{
passive: true,
once: true,
} as AddEventListenerOptions,
);
target.addEventListener(
"touchmove",
cancel as EventListener,
{
passive: true,
once: true,
} as AddEventListenerOptions,
);
target.addEventListener(
"keydown",
keydownHandler as EventListener,
{
once: true,
} as AddEventListenerOptions,
);
}
}, []);
const removeRefs = useCallback(() => {
beforeTopRef.current = null;
targetRef.current = null;
didUserScrollRef.current = false;
compensatedRef.current = false;
}, []);
const performCompensation = useCallback(
(element: T) => {
if (compensatedRef.current) return;
const beforeTop = beforeTopRef.current;
const target = targetRef.current;
if (beforeTop == null || !target) return;
if (didUserScrollRef.current) {
removeRefs();
return;
}
const afterTop = element.getBoundingClientRect().top;
const delta = afterTop - beforeTop;
if (Math.abs(delta) < 1) {
removeRefs();
return;
}
if (isWindow(target)) {
window.scrollBy({ top: delta, left: 0 });
} else {
(target as Element).scrollTop += delta;
}
compensatedRef.current = true;
removeRefs();
},
[removeRefs],
);
const startPreserveScroll = useCallback(() => {
if (!enabled) return;
const element = elementRef.current;
if (!element || !element.getBoundingClientRect) return;
const rect = element.getBoundingClientRect();
beforeTopRef.current = rect.top;
targetRef.current =
options?.getScrollTarget?.(element) ??
findNearestScrollContainer(element);
didUserScrollRef.current = false;
attachScrollListener();
}, [attachScrollListener, enabled, options]);
const compensateInLayout = useCallback(() => {
if (!enabled) return;
const element = elementRef.current;
if (!element) return;
performCompensation(element);
}, [enabled, performCompensation]);
useLayoutEffect(() => {
compensateInLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, layoutDeps);
return [elementRef, startPreserveScroll];
}
export default usePreserveRelativeScroll;
@@ -0,0 +1,79 @@
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { prisma } from "@langfuse/shared/src/db";
import { redis } from "@langfuse/shared/src/server";
import { type NextApiRequest, type NextApiResponse } from "next";
import { hasEntitlementBasedOnPlan } from "@/src/features/entitlements/server/hasEntitlement";
import {
LangfuseNotFoundError,
UnauthorizedError,
ForbiddenError,
} from "@langfuse/shared";
export default withMiddlewares({
DELETE: handleDeleteBlobStorageIntegration,
});
async function handleDeleteBlobStorageIntegration(
req: NextApiRequest,
res: NextApiResponse,
) {
// CHECK AUTH
const authCheck = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(req.headers.authorization);
if (!authCheck.validKey) {
throw new UnauthorizedError(authCheck.error ?? "Unauthorized");
}
// Check if using an organization API key
if (
authCheck.scope.accessLevel !== "organization" ||
!authCheck.scope.orgId
) {
throw new ForbiddenError(
"Organization-scoped API key required for this operation.",
);
}
// Check scheduled-blob-exports entitlement
if (
!hasEntitlementBasedOnPlan({
plan: authCheck.scope.plan,
entitlement: "scheduled-blob-exports",
})
) {
throw new ForbiddenError(
"scheduled-blob-exports entitlement required for this feature.",
);
}
const { id } = req.query;
if (!id || typeof id !== "string") {
throw new Error("Invalid integration ID");
}
// Check if the integration exists and belongs to a project in the organization
const integration = await prisma.blobStorageIntegration.findUnique({
where: { projectId: id },
include: {
project: {
select: { orgId: true },
},
},
});
if (!integration || integration.project.orgId !== authCheck.scope.orgId) {
throw new LangfuseNotFoundError("Blob storage integration not found");
}
// Delete the integration
await prisma.blobStorageIntegration.delete({
where: { projectId: id },
});
return res.status(200).json({
message: "Blob storage integration successfully deleted",
});
}
@@ -0,0 +1,194 @@
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { prisma } from "@langfuse/shared/src/db";
import { redis } from "@langfuse/shared/src/server";
import { type NextApiRequest, type NextApiResponse } from "next";
import { hasEntitlementBasedOnPlan } from "@/src/features/entitlements/server/hasEntitlement";
import {
CreateBlobStorageIntegrationRequest,
type BlobStorageIntegrationResponseType,
} from "@/src/features/public-api/types/blob-storage-integrations";
import {
LangfuseNotFoundError,
UnauthorizedError,
ForbiddenError,
} from "@langfuse/shared";
export default withMiddlewares({
GET: handleGetBlobStorageIntegrations,
PUT: handleUpsertBlobStorageIntegration,
});
async function handleGetBlobStorageIntegrations(
req: NextApiRequest,
res: NextApiResponse,
) {
// CHECK AUTH
const authCheck = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(req.headers.authorization);
if (!authCheck.validKey) {
throw new UnauthorizedError(authCheck.error ?? "Unauthorized");
}
// Check if using an organization API key
if (
authCheck.scope.accessLevel !== "organization" ||
!authCheck.scope.orgId
) {
throw new ForbiddenError(
"Organization-scoped API key required for this operation.",
);
}
// Check scheduled-blob-exports entitlement
if (
!hasEntitlementBasedOnPlan({
plan: authCheck.scope.plan,
entitlement: "scheduled-blob-exports",
})
) {
throw new ForbiddenError(
"scheduled-blob-exports entitlement required for this feature.",
);
}
// Get all projects for the organization
const projects = await prisma.project.findMany({
where: { orgId: authCheck.scope.orgId },
select: { id: true },
});
// Get all blob storage integrations for these projects
const integrations = await prisma.blobStorageIntegration.findMany({
where: {
projectId: { in: projects.map((p) => p.id) },
},
});
// Transform to API response format, exclude secretAccessKey
const responseData: BlobStorageIntegrationResponseType[] = integrations.map(
(integration) => ({
id: integration.projectId, // Using projectId as ID since it's the primary key
projectId: integration.projectId,
type: integration.type,
bucketName: integration.bucketName,
endpoint: integration.endpoint,
region: integration.region,
accessKeyId: integration.accessKeyId,
prefix: integration.prefix,
exportFrequency: integration.exportFrequency,
enabled: integration.enabled,
forcePathStyle: integration.forcePathStyle,
fileType: integration.fileType,
exportMode: integration.exportMode,
exportStartDate: integration.exportStartDate,
nextSyncAt: integration.nextSyncAt,
lastSyncAt: integration.lastSyncAt,
createdAt: integration.createdAt,
updatedAt: integration.updatedAt,
}),
);
return res.status(200).json({
data: responseData,
});
}
async function handleUpsertBlobStorageIntegration(
req: NextApiRequest,
res: NextApiResponse,
) {
// CHECK AUTH
const authCheck = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(req.headers.authorization);
if (!authCheck.validKey) {
throw new UnauthorizedError(authCheck.error ?? "Unauthorized");
}
// Check if using an organization API key
if (
authCheck.scope.accessLevel !== "organization" ||
!authCheck.scope.orgId
) {
throw new ForbiddenError(
"Organization-scoped API key required for this operation.",
);
}
// Check scheduled-blob-exports entitlement
if (
!hasEntitlementBasedOnPlan({
plan: authCheck.scope.plan,
entitlement: "scheduled-blob-exports",
})
) {
throw new ForbiddenError(
"scheduled-blob-exports entitlement required for this feature.",
);
}
// Validate request body
const validatedData = CreateBlobStorageIntegrationRequest.parse(req.body);
// Check if the project exists and belongs to the organization
const project = await prisma.project.findUnique({
where: { id: validatedData.projectId },
select: { id: true, orgId: true },
});
if (!project || project.orgId !== authCheck.scope.orgId) {
throw new LangfuseNotFoundError("Project not found");
}
// Prepare data for database
const dbData = {
projectId: validatedData.projectId,
type: validatedData.type,
bucketName: validatedData.bucketName,
endpoint: validatedData.endpoint || null,
region: validatedData.region,
accessKeyId: validatedData.accessKeyId || null,
secretAccessKey: validatedData.secretAccessKey || null,
prefix: validatedData.prefix,
exportFrequency: validatedData.exportFrequency,
enabled: validatedData.enabled,
forcePathStyle: validatedData.forcePathStyle,
fileType: validatedData.fileType,
exportMode: validatedData.exportMode,
exportStartDate: validatedData.exportStartDate || null,
};
// Upsert the integration (create or update)
const integration = await prisma.blobStorageIntegration.upsert({
where: { projectId: validatedData.projectId },
update: dbData,
create: dbData,
});
// Transform to API response format, exclude secretAccessKey
const responseData: BlobStorageIntegrationResponseType = {
id: integration.projectId, // Using projectId as ID since it's the primary key
projectId: integration.projectId,
type: integration.type,
bucketName: integration.bucketName,
endpoint: integration.endpoint,
region: integration.region,
accessKeyId: integration.accessKeyId,
prefix: integration.prefix,
exportFrequency: integration.exportFrequency,
enabled: integration.enabled,
forcePathStyle: integration.forcePathStyle,
fileType: integration.fileType,
exportMode: integration.exportMode,
exportStartDate: integration.exportStartDate,
nextSyncAt: integration.nextSyncAt,
lastSyncAt: integration.lastSyncAt,
createdAt: integration.createdAt,
updatedAt: integration.updatedAt,
};
return res.status(200).json(responseData);
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.109.0",
"version": "3.110.0",
"description": "",
"license": "MIT",
"private": true,
@@ -12,7 +12,7 @@
"coverage": "vitest run --coverage",
"start": "dotenv -e ../.env -- node dist/index.js",
"build": "tsc",
"dev": "dotenv -e ../.env -- nodemon src/index.ts",
"dev": "dotenv -e ../.env -- tsx watch --clear-screen=false --include '../packages/shared/dist/*' src/index.ts",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"refill-ingestion-events": "dotenv -e ../.env -- tsx src/scripts/replayIngestionEvents/s3-ingestion-event-replay.ts",
@@ -77,10 +77,10 @@
"eslint-plugin-prettier": "^5.1.3",
"kysely-codegen": "^0.16.8",
"msw": "^2.6.5",
"nodemon": "^3.1.7",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
"tsx": "^4.20.5",
"typescript": "^5.7.2",
"vitest": "^3.2.4",
"wait-for-expect": "^3.0.2"
+5
View File
@@ -112,6 +112,11 @@ if (env.QUEUE_CONSUMER_CREATE_EVAL_QUEUE_IS_ENABLED === "true") {
evalJobCreatorQueueProcessor,
{
concurrency: env.LANGFUSE_EVAL_CREATOR_WORKER_CONCURRENCY,
limiter: {
// Process at most `max` jobs per 2 seconds globally
max: env.LANGFUSE_EVAL_CREATOR_WORKER_CONCURRENCY,
duration: 2_000,
},
},
);
}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.109.0";
export const VERSION = "v3.110.0";
+7
View File
@@ -21,6 +21,7 @@ import { env } from "../env";
import { IngestionService } from "../services/IngestionService";
import { prisma } from "@langfuse/shared/src/db";
import { ClickhouseWriter } from "../services/ClickhouseWriter";
import { ForbiddenError } from "@langfuse/shared";
export const otelIngestionQueueProcessor: Processor = async (
job: Job<TQueueJobTypes[QueueName.OtelIngestionQueue]>,
@@ -136,6 +137,12 @@ export const otelIngestionQueueProcessor: Processor = async (
].flat(),
);
} catch (e) {
if (e instanceof ForbiddenError) {
traceException(e);
logger.warn(`Failed to parse otel observation: ${e.message}`, e);
return;
}
logger.error(
`Failed job otel ingestion processing for ${job.data.payload.authCheck.scope.projectId}`,
e,