Skip to content

Commit 6cb87cc

Browse files
fix: Fix shareable playground build events and message rendering (#12421)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 811ac4b commit 6cb87cc

8 files changed

Lines changed: 977 additions & 400 deletions

File tree

src/backend/base/langflow/api/build.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,26 @@ async def start_flow_build(
6969
current_user: CurrentActiveUser,
7070
queue_service: JobQueueService,
7171
flow_name: str | None = None,
72+
source_flow_id: uuid.UUID | None = None,
7273
) -> str:
7374
"""Start the flow build process by setting up the queue and starting the build task.
7475
76+
Args:
77+
flow_id: The flow ID used for tracking, sessions, and messages.
78+
background_tasks: FastAPI background tasks handler.
79+
inputs: Optional input values for the flow.
80+
data: Optional flow data request.
81+
files: Optional list of file paths.
82+
stop_component_id: Optional component ID to stop the build at.
83+
start_component_id: Optional component ID to start the build from.
84+
log_builds: Whether to log builds.
85+
current_user: The current authenticated user.
86+
queue_service: The job queue service.
87+
flow_name: Optional flow name override.
88+
source_flow_id: If provided, the actual flow ID to load from DB.
89+
Used by public flows where flow_id is a virtual UUID for session isolation
90+
but the flow data must be loaded from the original flow in the database.
91+
7592
Returns:
7693
the job_id.
7794
"""
@@ -90,6 +107,7 @@ async def start_flow_build(
90107
log_builds=log_builds,
91108
current_user=current_user,
92109
flow_name=flow_name,
110+
source_flow_id=source_flow_id,
93111
)
94112
queue_service.start_job(job_id, task_coro)
95113
except Exception as e:
@@ -209,6 +227,7 @@ async def generate_flow_events(
209227
log_builds: bool,
210228
current_user: CurrentActiveUser,
211229
flow_name: str | None = None,
230+
source_flow_id: uuid.UUID | None = None,
212231
) -> None:
213232
"""Generate events for flow building process.
214233
@@ -283,13 +302,19 @@ async def create_graph(fresh_session, flow_id_str: str, flow_name: str | None) -
283302
effective_session_id = flow_id_str
284303

285304
if not data:
286-
return await build_graph_from_db(
287-
flow_id=flow_id,
305+
# For public flows, source_flow_id is the real DB ID, flow_id is virtual.
306+
# Load from DB using the real ID, then override graph.flow_id with virtual.
307+
db_flow_id = source_flow_id if source_flow_id is not None else flow_id
308+
graph = await build_graph_from_db(
309+
flow_id=db_flow_id,
288310
session=fresh_session,
289311
chat_service=chat_service,
290312
user_id=str(current_user.id),
291313
session_id=effective_session_id,
292314
)
315+
if source_flow_id is not None:
316+
graph.flow_id = str(flow_id)
317+
return graph
293318

294319
if not flow_name:
295320
result = await fresh_session.exec(select(Flow.name).where(Flow.id == flow_id))

src/backend/base/langflow/api/v1/chat.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -629,9 +629,11 @@ async def build_public_tmp(
629629
client_id = request.cookies.get("client_id")
630630
owner_user, new_flow_id = await verify_public_flow_and_get_user(flow_id=flow_id, client_id=client_id)
631631

632-
# Start the flow build using the new flow ID
632+
# flow_id=new_flow_id for tracking/sessions/messages (virtual, per-user isolation).
633+
# source_flow_id=flow_id to load the actual flow data from the database.
633634
job_id = await start_flow_build(
634635
flow_id=new_flow_id,
636+
source_flow_id=flow_id,
635637
background_tasks=background_tasks,
636638
inputs=inputs,
637639
data=data,
@@ -655,3 +657,54 @@ async def build_public_tmp(
655657
queue_service=queue_service,
656658
event_delivery=event_delivery,
657659
)
660+
661+
662+
@router.get("/build_public_tmp/{job_id}/events")
663+
async def get_build_events_public(
664+
job_id: str,
665+
queue_service: Annotated[JobQueueService, Depends(get_queue_service)],
666+
*,
667+
event_delivery: EventDeliveryType = EventDeliveryType.STREAMING,
668+
):
669+
"""Get events for a public flow build job.
670+
671+
This endpoint does not require authentication, matching the public build endpoint.
672+
It is used by the shareable playground to consume build events.
673+
"""
674+
return await get_flow_events_response(
675+
job_id=job_id,
676+
queue_service=queue_service,
677+
event_delivery=event_delivery,
678+
)
679+
680+
681+
@router.post(
682+
"/build_public_tmp/{job_id}/cancel",
683+
response_model=CancelFlowResponse,
684+
)
685+
async def cancel_build_public(
686+
job_id: str,
687+
queue_service: Annotated[JobQueueService, Depends(get_queue_service)],
688+
):
689+
"""Cancel a public flow build job.
690+
691+
This endpoint does not require authentication, matching the public build endpoint.
692+
It is used by the shareable playground to cancel builds.
693+
"""
694+
try:
695+
cancellation_success = await cancel_flow_build(job_id=job_id, queue_service=queue_service)
696+
697+
if cancellation_success:
698+
return CancelFlowResponse(success=True, message="Flow build cancelled successfully")
699+
return CancelFlowResponse(success=False, message="Failed to cancel flow build")
700+
except asyncio.CancelledError:
701+
await logger.aerror(f"Failed to cancel public flow build for job_id {job_id} (CancelledError caught)")
702+
return CancelFlowResponse(success=False, message="Failed to cancel flow build")
703+
except ValueError as exc:
704+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
705+
except JobQueueNotFoundError as exc:
706+
await logger.aerror(f"Public job not found: {job_id}. Error: {exc!s}")
707+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Job not found: {exc!s}") from exc
708+
except Exception as exc:
709+
await logger.aexception(f"Error cancelling public flow build for job_id {job_id}: {exc}")
710+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc

src/backend/tests/unit/test_chat_endpoint.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,3 +432,127 @@ async def mock_cancel_flow_build_with_cancelled_error(*_args, **_kwargs):
432432
finally:
433433
# Restore the original function to avoid affecting other tests
434434
monkeypatch.setattr(langflow.api.v1.chat, "cancel_flow_build", original_cancel_flow_build)
435+
436+
437+
@pytest.mark.benchmark
438+
async def test_should_have_public_events_endpoint_accessible_without_auth(client, logged_in_headers): # noqa: ARG001
439+
"""Test that public events endpoint exists and is accessible without authentication.
440+
441+
Bug: After sending a message in the Shareable Playground, the chat input resets
442+
but no response is rendered. The root cause is that the events endpoint
443+
(/build/{job_id}/events) requires authentication, which the unauthenticated
444+
shareable playground user does not have.
445+
446+
This test proves:
447+
1. The PUBLIC events endpoint exists and responds without auth (404 = route exists, job not found)
448+
2. The AUTHENTICATED events endpoint rejects unauthenticated requests (403)
449+
"""
450+
fake_job_id = str(uuid.uuid4())
451+
452+
# Assert 1 — the PUBLIC events endpoint is accessible without auth
453+
# Returns 404 "Job not found" (route exists, but job doesn't) — NOT 401/403
454+
events_response = await client.get(
455+
f"api/v1/build_public_tmp/{fake_job_id}/events?event_delivery=polling",
456+
headers={"Accept": "application/x-ndjson"},
457+
)
458+
assert events_response.status_code == codes.NOT_FOUND
459+
460+
# The key proof: the public endpoint responded with 404 (route exists, job not found)
461+
# rather than 401/403 (authentication required). Before the fix, this endpoint
462+
# didn't exist at all and would return 404 for the route, not the job.
463+
assert "Job not found" in events_response.json()["detail"]
464+
465+
466+
@pytest.mark.benchmark
467+
async def test_should_have_public_cancel_endpoint_accessible_without_auth(client, logged_in_headers): # noqa: ARG001
468+
"""Test that public cancel endpoint exists and is accessible without authentication.
469+
470+
Same root cause as the events bug: the cancel endpoint requires auth
471+
but the shareable playground user is unauthenticated.
472+
"""
473+
fake_job_id = str(uuid.uuid4())
474+
475+
# The PUBLIC cancel endpoint is accessible without auth
476+
# Returns 404 "Job not found" (route exists, but job doesn't) — NOT 401/403
477+
cancel_response = await client.post(
478+
f"api/v1/build_public_tmp/{fake_job_id}/cancel",
479+
headers={"Content-Type": "application/json"},
480+
)
481+
assert cancel_response.status_code == codes.NOT_FOUND
482+
assert "Job not found" in cancel_response.json()["detail"]
483+
484+
485+
@pytest.mark.benchmark
486+
async def test_build_public_tmp_ignores_data_parameter(client, json_memory_chatbot_no_llm, logged_in_headers):
487+
"""Test that build_public_tmp endpoint silently ignores data parameter for security.
488+
489+
Security Test: Verifies that when a user attempts to provide custom flow data
490+
to the public flow endpoint, FastAPI silently ignores the extra parameter and
491+
the endpoint functions normally using the stored flow data from the database.
492+
"""
493+
# Create a flow
494+
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
495+
496+
# Make the flow public
497+
response = await client.patch(
498+
f"api/v1/flows/{flow_id}",
499+
json={"access_type": "PUBLIC"},
500+
headers=logged_in_headers,
501+
)
502+
assert response.status_code == codes.OK
503+
504+
# Create malicious flow data with different structure
505+
malicious_data = {"nodes": [{"id": "malicious", "data": {"type": "CustomComponent"}}], "edges": []}
506+
507+
# Set a client_id cookie
508+
client.cookies.set("client_id", "test-security-client-123")
509+
510+
# Attempt to build with malicious data - FastAPI will silently ignore it
511+
response = await client.post(
512+
f"api/v1/build_public_tmp/{flow_id}/flow",
513+
json={
514+
"inputs": {"session": "test_session"},
515+
"data": malicious_data, # This will be silently ignored by FastAPI
516+
},
517+
headers={"Content-Type": "application/json"},
518+
)
519+
520+
# Verify the request succeeded - the data parameter is simply ignored
521+
assert response.status_code == codes.OK
522+
response_data = response.json()
523+
assert "job_id" in response_data
524+
525+
526+
@pytest.mark.benchmark
527+
async def test_build_public_tmp_without_data_parameter(client, json_memory_chatbot_no_llm, logged_in_headers):
528+
"""Test that build_public_tmp endpoint works without data parameter.
529+
530+
Security Test: Verifies that when no data parameter is provided, the endpoint
531+
works normally and returns a job_id. This proves the data parameter is optional
532+
and the stored flow definition is always used.
533+
"""
534+
# Create a flow
535+
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
536+
537+
# Make the flow public
538+
response = await client.patch(
539+
f"api/v1/flows/{flow_id}",
540+
json={"access_type": "PUBLIC"},
541+
headers=logged_in_headers,
542+
)
543+
assert response.status_code == codes.OK
544+
545+
# Set a client_id cookie
546+
client.cookies.set("client_id", "test-no-data-client")
547+
548+
# Build without providing data parameter
549+
response = await client.post(
550+
f"api/v1/build_public_tmp/{flow_id}/flow",
551+
json={"inputs": {"session": "test_session"}},
552+
headers={"Content-Type": "application/json"},
553+
)
554+
555+
# Verify the request succeeded
556+
assert response.status_code == codes.OK
557+
response_data = response.json()
558+
assert "job_id" in response_data

src/frontend/src/components/core/playgroundComponent/chat-view/utils/message-event-handler.ts

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,60 @@
1+
import { useMessagesStore } from "@/stores/messagesStore";
12
import type { Message } from "@/types/messages";
23
import { removeMessages, updateMessage } from "./message-utils";
34

45
/**
56
* Handles message-related events from the build process.
6-
* This keeps all chat message logic within the chat-view scope.
7+
* Updates both React Query cache (used by the internal playground)
8+
* and useMessagesStore (used by the shareable playground / IOModal).
79
*/
810
export const handleMessageEvent = (
911
eventType: string,
1012
data: unknown,
1113
): boolean => {
1214
switch (eventType) {
1315
case "add_message": {
14-
// Add/update message in React Query cache (replaces placeholder if exists)
16+
// Update React Query cache (internal playground)
1517
updateMessage(data as Message);
18+
// Update Zustand store (shareable playground / IOModal)
19+
useMessagesStore.getState().addMessage(data as Message);
1620
return true;
1721
}
1822
case "token": {
19-
// Update message text in React Query cache for streaming
20-
updateMessage({
21-
id: data.id,
22-
flow_id: data.flow_id || "",
23-
session_id: data.session_id || "",
24-
text: data.chunk || "",
25-
sender: data.sender || "Machine",
26-
sender_name: data.sender_name || "AI",
27-
timestamp: data.timestamp || new Date().toISOString(),
28-
files: data.files || [],
29-
edit: data.edit || false,
30-
background_color: data.background_color || "",
31-
text_color: data.text_color || "",
32-
properties: { ...data.properties, state: "partial" },
33-
} as Message);
23+
const d = data as Record<string, unknown>;
24+
const tokenMessage = {
25+
id: d.id,
26+
flow_id: d.flow_id || "",
27+
session_id: d.session_id || "",
28+
text: d.chunk || "",
29+
sender: d.sender || "Machine",
30+
sender_name: d.sender_name || "AI",
31+
timestamp: d.timestamp || new Date().toISOString(),
32+
files: d.files || [],
33+
edit: d.edit || false,
34+
background_color: d.background_color || "",
35+
text_color: d.text_color || "",
36+
properties: { ...(d.properties as object), state: "partial" },
37+
} as Message;
38+
// Update React Query cache (internal playground)
39+
updateMessage(tokenMessage);
40+
// Update Zustand store (shareable playground / IOModal)
41+
useMessagesStore.getState().addMessage(tokenMessage);
3442
return true;
3543
}
3644
case "remove_message": {
37-
// Remove message from React Query cache
38-
removeMessages([data.id], data.session_id || "", data.flow_id || "");
45+
const rm = data as Record<string, string>;
46+
// Remove from React Query cache
47+
removeMessages([rm.id], rm.session_id || "", rm.flow_id || "");
48+
// Remove from Zustand store
49+
useMessagesStore.getState().removeMessage(data as Message);
3950
return true;
4051
}
4152
case "error": {
42-
if (data?.category === "error") {
43-
// Add error message to React Query cache
53+
if ((data as Record<string, unknown>)?.category === "error") {
54+
// Update React Query cache
4455
updateMessage(data as Message);
56+
// Update Zustand store
57+
useMessagesStore.getState().addMessage(data as Message);
4558
}
4659
return true;
4760
}

src/frontend/src/customization/utils/custom-buildUtils.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ export const customBuildUrl = (flowId: string, playgroundPage?: boolean) => {
44
return `${getBaseUrl()}${playgroundPage ? "build_public_tmp" : "build"}/${flowId}/flow`;
55
};
66

7-
export const customCancelBuildUrl = (jobId: string) => {
8-
return `${getBaseUrl()}build/${jobId}/cancel`;
7+
export const customCancelBuildUrl = (
8+
jobId: string,
9+
playgroundPage?: boolean,
10+
) => {
11+
return `${getBaseUrl()}${playgroundPage ? "build_public_tmp" : "build"}/${jobId}/cancel`;
912
};
1013

11-
export const customEventsUrl = (jobId: string) => {
12-
return `${getBaseUrl()}build/${jobId}/events`;
14+
export const customEventsUrl = (jobId: string, playgroundPage?: boolean) => {
15+
return `${getBaseUrl()}${playgroundPage ? "build_public_tmp" : "build"}/${jobId}/events`;
1316
};

src/frontend/src/utils/buildUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ export async function buildFlowVertices({
344344

345345
const { job_id } = await buildResponse.json();
346346

347-
const cancelBuildUrl = customCancelBuildUrl(job_id);
347+
const cancelBuildUrl = customCancelBuildUrl(job_id, playgroundPage);
348348

349349
// Get the buildController from flowStore
350350
const buildController = new AbortController();
@@ -363,7 +363,7 @@ export async function buildFlowVertices({
363363
});
364364
useFlowStore.getState().setBuildController(buildController);
365365
// Then stream the events
366-
const eventsUrl = customEventsUrl(job_id);
366+
const eventsUrl = customEventsUrl(job_id, playgroundPage);
367367
const buildResults: Array<boolean> = [];
368368

369369
if (eventDelivery === EventDeliveryType.STREAMING) {

0 commit comments

Comments
 (0)