From 15ecfb15bc1003ca970462c25ec0fd5416802c38 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Fri, 14 Aug 2026 15:04:11 -0400 Subject: [PATCH 1/2] Allow browsing-mode users to get the backup Parsons problem Previously /ns/coach/parsons_scaffolding required login unconditionally (Depends(auth_manager)), so logged-out/browsing-mode users got a hard 401 even when the exercise had a pre-authored, non-AI backup Parsons problem configured. Swap in an optional-auth dependency and, for anonymous users, serve the static backup when one exists; otherwise keep requiring login for the CodeTailor/LLM-personalized path. Co-Authored-By: Claude Sonnet 5 --- bases/rsptx/book_server_api/routers/coach.py | 33 ++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py index 4024a5c51..32a079848 100644 --- a/bases/rsptx/book_server_api/routers/coach.py +++ b/bases/rsptx/book_server_api/routers/coach.py @@ -33,7 +33,7 @@ from .assessment import get_question_source, SelectQRequest # Import function for fetching api - comment out for DEV purposes -from rsptx.auth.session import auth_manager +from rsptx.auth.session import auth_manager, NotAuthenticatedException from rsptx.db.crud.crud import fetch_api_token from rsptx.db.crud.course import fetch_course from rsptx.db.crud.question import fetch_question @@ -199,10 +199,23 @@ async def get_question_html(request: Request, div_id: str): return {"html": html} +async def _get_optional_user(request: Request): + """Return the authenticated user, or None for anonymous/browsing-mode visitors. + + Unlike ``Depends(auth_manager)``, this never raises -- it lets the route + itself decide what to do for logged-out users (e.g. serve the static + backup Parsons problem instead of the CodeTailor/LLM-personalized one). + """ + try: + return await auth_manager(request) + except NotAuthenticatedException: + return None + + # @router.post("/ns/coach/parsons_scaffolding") @router.post("/parsons_scaffolding") async def parsons_scaffolding( - request: Request, course: Optional[str], user=Depends(auth_manager) + request: Request, course: Optional[str], user=Depends(_get_optional_user) ): # Get `course` directly from the query string rslogger.warning(f"URL seen: {request.url}") @@ -240,6 +253,22 @@ async def parsons_scaffolding( else: parsonsexample_code = "LLM-example" + if user is None: + # Browsing-mode/anonymous visitors can get the pre-authored backup + # Parsons problem, but never the CodeTailor/LLM-personalized one -- + # that requires an account so we can call out to the LLM on their + # behalf and track usage. + if parsonsexample != "LLM-example" and parsonsexample_html: + return _build_static_parsons_response( + parsonsexample_code, parsons_attrs, personalization_level + ) + return JSONResponse( + content={ + "detail": "You need to be logged in to Runestone to access this resource" + }, + status_code=status.HTTP_401_UNAUTHORIZED, + ) + # Fetch API token api_token = None try: From 78fcb01f032e96c519b3b51c6f2bfc13cfa1cd67 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Fri, 14 Aug 2026 15:27:20 -0400 Subject: [PATCH 2/2] Move get_optional_user into rsptx.auth.session for reuse Per review feedback: relocate the optional-auth dependency out of coach.py and into components/rsptx/auth/session.py (alongside auth_manager) so other endpoints can reuse it, and drop the leading underscore now that it's a public helper. Co-Authored-By: Claude Sonnet 5 --- bases/rsptx/book_server_api/routers/coach.py | 17 ++--------------- components/rsptx/auth/session.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py index 32a079848..5431f2aaf 100644 --- a/bases/rsptx/book_server_api/routers/coach.py +++ b/bases/rsptx/book_server_api/routers/coach.py @@ -33,7 +33,7 @@ from .assessment import get_question_source, SelectQRequest # Import function for fetching api - comment out for DEV purposes -from rsptx.auth.session import auth_manager, NotAuthenticatedException +from rsptx.auth.session import get_optional_user from rsptx.db.crud.crud import fetch_api_token from rsptx.db.crud.course import fetch_course from rsptx.db.crud.question import fetch_question @@ -199,23 +199,10 @@ async def get_question_html(request: Request, div_id: str): return {"html": html} -async def _get_optional_user(request: Request): - """Return the authenticated user, or None for anonymous/browsing-mode visitors. - - Unlike ``Depends(auth_manager)``, this never raises -- it lets the route - itself decide what to do for logged-out users (e.g. serve the static - backup Parsons problem instead of the CodeTailor/LLM-personalized one). - """ - try: - return await auth_manager(request) - except NotAuthenticatedException: - return None - - # @router.post("/ns/coach/parsons_scaffolding") @router.post("/parsons_scaffolding") async def parsons_scaffolding( - request: Request, course: Optional[str], user=Depends(_get_optional_user) + request: Request, course: Optional[str], user=Depends(get_optional_user) ): # Get `course` directly from the query string rslogger.warning(f"URL seen: {request.url}") diff --git a/components/rsptx/auth/session.py b/components/rsptx/auth/session.py index ffa9a9097..c5ff6d65f 100644 --- a/components/rsptx/auth/session.py +++ b/components/rsptx/auth/session.py @@ -133,6 +133,19 @@ async def _load_user(user_id: str) -> AuthUserValidator: load_user = cast(Callable[[str], Awaitable[AuthUserValidator]], _load_user) +async def get_optional_user(request: Request) -> Optional[AuthUserValidator]: + """Return the authenticated user, or None for anonymous/browsing-mode visitors. + + Unlike ``Depends(auth_manager)``, this never raises -- it lets the route + itself decide what to do for logged-out users (e.g. serve a static/backup + resource instead of one that requires an account). + """ + try: + return await auth_manager(request) + except NotAuthenticatedException: + return None + + async def is_instructor( request: Request, user: Optional[AuthUserValidator] = None ) -> bool: