From 7c2311ae566b6f8fa2c543830b31dfa568b520dc Mon Sep 17 00:00:00 2001 From: Matthias Vogler Date: Tue, 22 Sep 2026 13:06:10 +0200 Subject: [PATCH 1/2] filter keys sent via /project/thread/{threadid} endpoint --- backend/chainlit/emitter.py | 9 +++++-- backend/chainlit/server.py | 34 ++++++++++++++++++------- backend/chainlit/thread_metadata.py | 39 +++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 backend/chainlit/thread_metadata.py diff --git a/backend/chainlit/emitter.py b/backend/chainlit/emitter.py index 97a01106ee..9222ecd322 100644 --- a/backend/chainlit/emitter.py +++ b/backend/chainlit/emitter.py @@ -13,6 +13,7 @@ from chainlit.mode import Mode from chainlit.session import BaseSession, WebsocketSession from chainlit.step import StepDict +from chainlit.thread_metadata import public_thread from chainlit.types import ( AskActionResponse, AskElementResponse, @@ -193,8 +194,12 @@ def emit_call(self): return self._get_session_property("emit_call") def resume_thread(self, thread_dict: ThreadDict): - """Send a thread to the UI to resume it""" - return self.emit("resume_thread", thread_dict) + """Send a thread to the UI with only public metadata. + + Args: + thread_dict: Internal thread whose session metadata must be preserved. + """ + return self.emit("resume_thread", public_thread(thread_dict, include_settings=True)) def send_resume_thread_error(self, error: str): """Send a thread resume error to the UI""" diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index 68238cd254..931eac5e4e 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -60,6 +60,7 @@ from chainlit.markdown import get_markdown_str from chainlit.oauth_providers import get_oauth_provider from chainlit.secret import random_secret +from chainlit.thread_metadata import public_thread from chainlit.types import ( AskFileSpec, CallActionRequest, @@ -970,7 +971,13 @@ async def get_user_threads( payload: GetThreadsRequest, current_user: UserParam, ): - """Get the threads page by page.""" + """Get paginated threads with public metadata. + + Args: + request: Incoming HTTP request. + payload: Pagination and thread filters. + current_user: Authenticated owner of the requested threads. + """ data_layer = get_data_layer() @@ -989,7 +996,9 @@ async def get_user_threads( payload.filter.userId = current_user.id res = await data_layer.list_threads(payload.pagination, payload.filter) - return JSONResponse(content=res.to_dict()) + response = res.to_dict() + response["data"] = [public_thread(thread) for thread in response["data"]] + return JSONResponse(content=response) @router.get("/project/thread/{thread_id}") @@ -998,7 +1007,13 @@ async def get_thread( thread_id: str, current_user: UserParam, ): - """Get a specific thread.""" + """Get an owner's thread with public metadata. + + Args: + request: Incoming HTTP request. + thread_id: Identifier of the requested thread. + current_user: Authenticated thread owner. + """ data_layer = get_data_layer() if not data_layer: @@ -1010,7 +1025,7 @@ async def get_thread( await is_thread_author(current_user.identifier, thread_id) res = await data_layer.get_thread(thread_id) - return JSONResponse(content=res) + return JSONResponse(content=public_thread(res, include_settings=True)) @router.get("/project/share/{thread_id}") @@ -1024,6 +1039,11 @@ async def get_shared_thread( This endpoint is separate from the resume endpoint and does not require the caller to be the author of the thread. It only returns the thread if its metadata contains is_shared=True. Otherwise, it returns 404 to avoid leaking existence. + + Args: + request: Incoming HTTP request. + thread_id: Identifier of the requested shared thread. + current_user: Viewer passed to the optional access callback. """ data_layer = get_data_layer() @@ -1061,11 +1081,7 @@ async def get_shared_thread( if (not user_can_view) and (not is_shared): raise HTTPException(status_code=404, detail="Thread not found") - metadata.pop("chat_profile", None) - metadata.pop("chat_settings", None) - metadata.pop("env", None) - thread["metadata"] = metadata - return JSONResponse(content=thread) + return JSONResponse(content=public_thread(thread)) @router.get("/project/thread/{thread_id}/element/{element_id}") diff --git a/backend/chainlit/thread_metadata.py b/backend/chainlit/thread_metadata.py new file mode 100644 index 0000000000..16e6ad28d0 --- /dev/null +++ b/backend/chainlit/thread_metadata.py @@ -0,0 +1,39 @@ +"""Filter thread metadata at browser response boundaries.""" + +import json +from typing import Optional + +from chainlit.types import ThreadDict + + +def public_thread( + thread: Optional[ThreadDict], *, include_settings: bool = False +) -> Optional[ThreadDict]: + """Copy a thread with only browser-visible metadata, preserving stored state. + + Args: + thread: Internal thread returned by the data layer, or None. + include_settings: Include profile and settings for an owner's chat view. + """ + if thread is None: + return None + + metadata = thread.get("metadata") or {} + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except (TypeError, ValueError): + metadata = {} + if not isinstance(metadata, dict): + metadata = {} + + allowed = {"is_shared", "viewer_read_only"} + if include_settings: + allowed.update({"chat_profile", "chat_settings"}) + + return { + **thread, + "metadata": { + key: value for key, value in metadata.items() if key in allowed + }, + } From bcbd925e76b65b44e08b8ea59321c036fa6ec654 Mon Sep 17 00:00:00 2001 From: Matthias Vogler Date: Tue, 22 Sep 2026 13:31:21 +0200 Subject: [PATCH 2/2] formatting --- backend/chainlit/emitter.py | 4 +++- backend/chainlit/thread_metadata.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/chainlit/emitter.py b/backend/chainlit/emitter.py index 9222ecd322..4b3caeae09 100644 --- a/backend/chainlit/emitter.py +++ b/backend/chainlit/emitter.py @@ -199,7 +199,9 @@ def resume_thread(self, thread_dict: ThreadDict): Args: thread_dict: Internal thread whose session metadata must be preserved. """ - return self.emit("resume_thread", public_thread(thread_dict, include_settings=True)) + return self.emit( + "resume_thread", public_thread(thread_dict, include_settings=True) + ) def send_resume_thread_error(self, error: str): """Send a thread resume error to the UI""" diff --git a/backend/chainlit/thread_metadata.py b/backend/chainlit/thread_metadata.py index 16e6ad28d0..d7e121b9bc 100644 --- a/backend/chainlit/thread_metadata.py +++ b/backend/chainlit/thread_metadata.py @@ -33,7 +33,5 @@ def public_thread( return { **thread, - "metadata": { - key: value for key, value in metadata.items() if key in allowed - }, + "metadata": {key: value for key, value in metadata.items() if key in allowed}, }