Skip to content

feat: rebuild shared projects with locks and git activity history#3

Merged
johannhartmann merged 2 commits intomainfrom
feature/shared-projects-history-rebuild
Feb 16, 2026
Merged

feat: rebuild shared projects with locks and git activity history#3
johannhartmann merged 2 commits intomainfrom
feature/shared-projects-history-rebuild

Conversation

@johannhartmann
Copy link
Member

Summary

  • Rebuild shared projects screen with project locking, member management, and git activity history
  • Add lock/unlock flow so only one user edits a project at a time, with modal prompts for session claiming
  • Surface GitLab commit history in the project UI via new git sync service endpoints

Test plan

  • Verify project locking prevents concurrent edits
  • Verify session claimed modal appears when another user takes over
  • Verify git activity history renders commit log correctly
  • Verify member management (add/remove) works end-to-end
  • Run uv run pytest — all new and existing tests pass
  • Run npm run build — no TypeScript errors

Replace plain-text loading indicators in Database, Design, Production,
and project-fallback panes with a shared PaneLoading component featuring
a centered Loader2 spinner and animated message text.
@johannhartmann johannhartmann merged commit 40bf736 into main Feb 16, 2026
16 of 21 checks passed
@johannhartmann johannhartmann deleted the feature/shared-projects-history-rebuild branch February 16, 2026 11:43
if str(e) == "not_found"
else 403
)
return JSONResponse({"error": str(e)}, status_code=code)

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

Copilot Autofix

AI 1 day ago

In general, to fix this kind of issue, avoid returning exception messages or stack traces directly in HTTP responses. Instead, log the detailed error server-side (if needed) and return a generic or controlled error code/message to the client.

For this specific case, we should stop sending str(e) back to the user and instead send a fixed error identifier derived from the same information currently used to compute the status code. The rest of the behavior (which status code is returned) can stay unchanged, preserving functionality from the client’s perspective while removing the information exposure.

Concretely, in src/runtimes/ws_server.py:

  • In api_project_members_add, replace {"error": str(e)} with a small set of fixed tokens based on the same string that is currently used ("not_authenticated", "not_found", or "forbidden").
  • In api_project_members_remove_by_sub, apply the same pattern to the except PermissionError as e: block.
  • Optionally, we can log the original exception message with the logging module (already imported as logging) for debugging, but avoid including it in the HTTP response.

No new imports or helper methods are strictly necessary; we can implement the mapping inline where the JSONResponse is constructed.

Suggested changeset 1
src/runtimes/ws_server.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py
--- a/src/runtimes/ws_server.py
+++ b/src/runtimes/ws_server.py
@@ -1135,14 +1135,20 @@
         p = await _ensure_project_owner_access_async(request, project_id=project_id)
         actor = _get_actor_from_request(request)
     except PermissionError as e:
+        error_str = str(e)
         code = (
             401
-            if str(e) == "not_authenticated"
+            if error_str == "not_authenticated"
             else 404
-            if str(e) == "not_found"
+            if error_str == "not_found"
             else 403
         )
-        return JSONResponse({"error": str(e)}, status_code=code)
+        # Do not expose internal exception messages to the client.
+        if error_str in ("not_authenticated", "not_found"):
+            error_code = error_str
+        else:
+            error_code = "forbidden"
+        return JSONResponse({"error": error_code}, status_code=code)
 
     from src.db.provisioning import hasura_client_from_env
     from src.projects.store import add_project_member
@@ -1185,14 +1186,20 @@
     try:
         p = await _ensure_project_owner_access_async(request, project_id=project_id)
     except PermissionError as e:
+        error_str = str(e)
         code = (
             401
-            if str(e) == "not_authenticated"
+            if error_str == "not_authenticated"
             else 404
-            if str(e) == "not_found"
+            if error_str == "not_found"
             else 403
         )
-        return JSONResponse({"error": str(e)}, status_code=code)
+        # Do not expose internal exception messages to the client.
+        if error_str in ("not_authenticated", "not_found"):
+            error_code = error_str
+        else:
+            error_code = "forbidden"
+        return JSONResponse({"error": error_code}, status_code=code)
     if target_sub == str(getattr(p, "owner_sub", "") or "").strip():
         return JSONResponse({"error": "cannot_remove_owner"}, status_code=400)
 
EOF
@@ -1135,14 +1135,20 @@
p = await _ensure_project_owner_access_async(request, project_id=project_id)
actor = _get_actor_from_request(request)
except PermissionError as e:
error_str = str(e)
code = (
401
if str(e) == "not_authenticated"
if error_str == "not_authenticated"
else 404
if str(e) == "not_found"
if error_str == "not_found"
else 403
)
return JSONResponse({"error": str(e)}, status_code=code)
# Do not expose internal exception messages to the client.
if error_str in ("not_authenticated", "not_found"):
error_code = error_str
else:
error_code = "forbidden"
return JSONResponse({"error": error_code}, status_code=code)

from src.db.provisioning import hasura_client_from_env
from src.projects.store import add_project_member
@@ -1185,14 +1186,20 @@
try:
p = await _ensure_project_owner_access_async(request, project_id=project_id)
except PermissionError as e:
error_str = str(e)
code = (
401
if str(e) == "not_authenticated"
if error_str == "not_authenticated"
else 404
if str(e) == "not_found"
if error_str == "not_found"
else 403
)
return JSONResponse({"error": str(e)}, status_code=code)
# Do not expose internal exception messages to the client.
if error_str in ("not_authenticated", "not_found"):
error_code = error_str
else:
error_code = "forbidden"
return JSONResponse({"error": error_code}, status_code=code)
if target_sub == str(getattr(p, "owner_sub", "") or "").strip():
return JSONResponse({"error": "cannot_remove_owner"}, status_code=400)

Copilot is powered by AI and may make mistakes. Always verify output.
if str(e) == "not_found"
else 403
)
return JSONResponse({"error": str(e)}, status_code=code)

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

Copilot Autofix

AI 1 day ago

In general, to fix this problem you should avoid returning raw exception messages or stack traces directly to users. Instead, map exceptions to controlled, stable error codes or messages, and log the detailed exception (including stack trace) on the server side for diagnostics. This ensures that clients receive only intended information, while developers retain full context via logs.

For this specific endpoint in src/runtimes/ws_server.py, we already derive an HTTP status code from the PermissionError’s string value, and we appear to be using constrained values like "not_authenticated" and "not_found". The safest way to preserve existing behavior while eliminating the information-exposure pattern is:

  • Replace {"error": str(e)} with a sanitized, controlled error code that does not rely on arbitrary exception messages.
  • Since the code already derives code from str(e), we can preserve the same categorization, but we should no longer echo str(e) itself back to the client. Instead, we can:
    • Map "not_authenticated""not_authenticated",
    • "not_found""not_found",
    • any other case → "forbidden".
  • Optionally, to aid debugging without changing imports, we can log the exception using the existing logging module already imported at the top of the file, but only on the server side and not in the response body.

Concretely, in api_project_members_remove_by_email around lines 1221–1231:

  • Keep the try/except PermissionError as e structure.
  • Inside the except, keep the code computation, but add a small mapping to a safe error_code string that does not depend directly on str(e) for the client response.
  • Optionally log the exception via logging.exception("...") or logging.warning(...).

This requires no new imports or external dependencies, as logging is already imported.

Suggested changeset 1
src/runtimes/ws_server.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py
--- a/src/runtimes/ws_server.py
+++ b/src/runtimes/ws_server.py
@@ -1228,7 +1228,16 @@
             if str(e) == "not_found"
             else 403
         )
-        return JSONResponse({"error": str(e)}, status_code=code)
+        # Log the detailed exception server-side, but do not expose it to the client.
+        logging.warning("PermissionError in api_project_members_remove_by_email: %s", e)
+        # Return a controlled error code instead of the raw exception message.
+        if str(e) == "not_authenticated":
+            error_code = "not_authenticated"
+        elif str(e) == "not_found":
+            error_code = "not_found"
+        else:
+            error_code = "forbidden"
+        return JSONResponse({"error": error_code}, status_code=code)
     if target_email == str(getattr(p, "owner_email", "") or "").strip().lower():
         return JSONResponse({"error": "cannot_remove_owner"}, status_code=400)
 
EOF
@@ -1228,7 +1228,16 @@
if str(e) == "not_found"
else 403
)
return JSONResponse({"error": str(e)}, status_code=code)
# Log the detailed exception server-side, but do not expose it to the client.
logging.warning("PermissionError in api_project_members_remove_by_email: %s", e)
# Return a controlled error code instead of the raw exception message.
if str(e) == "not_authenticated":
error_code = "not_authenticated"
elif str(e) == "not_found":
error_code = "not_found"
else:
error_code = "forbidden"
return JSONResponse({"error": error_code}, status_code=code)
if target_email == str(getattr(p, "owner_email", "") or "").strip().lower():
return JSONResponse({"error": "cannot_remove_owner"}, status_code=400)

Copilot is powered by AI and may make mistakes. Always verify output.
return _project_locked_json_response(e.locked_by_email, e.locked_at)
except PermissionError as e:
code = 401 if str(e) == "not_authenticated" else 404
return JSONResponse({"error": str(e)}, status_code=code)

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

Copilot Autofix

AI 1 day ago

In general, the fix is to avoid returning raw exception messages to the client and instead return a generic, non-sensitive error code or message. Optionally, we can log the original exception on the server for debugging purposes, but the client should only see controlled values.

For this specific code at lines 1321–1323 in src/runtimes/ws_server.py, the best fix without changing behavior is to keep using the existing distinction between "not_authenticated" and other PermissionError conditions but avoid exposing the full exception string. A safe pattern is:

  • Map "not_authenticated" to a stable, non-sensitive error identifier like "not_authenticated".
  • Map all other PermissionError cases to another stable identifier like "forbidden" or "not_found" (since the current code uses 404).
  • Optionally log the exception via the existing logging module for diagnostics, but do not send its message back to the client.

Concretely:

  • At the except PermissionError as e: block, introduce a small mapping from str(e) to a controlled error code. For instance, if str(e) == "not_authenticated", return {"error": "not_authenticated"} with status 401, otherwise return {"error": "not_found"} with status 404.
  • Do not change any imports; logging is already imported at line 7. We can log the exception using logging.warning or logging.error if desired, but this is optional security-wise.
  • The changes are confined to the except PermissionError handler within api_git_sync_project in src/runtimes/ws_server.py.
Suggested changeset 1
src/runtimes/ws_server.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py
--- a/src/runtimes/ws_server.py
+++ b/src/runtimes/ws_server.py
@@ -1319,8 +1319,13 @@
     except ProjectLockedError as e:
         return _project_locked_json_response(e.locked_by_email, e.locked_at)
     except PermissionError as e:
-        code = 401 if str(e) == "not_authenticated" else 404
-        return JSONResponse({"error": str(e)}, status_code=code)
+        # Do not expose raw exception messages to clients; map to controlled error codes.
+        is_not_authenticated = str(e) == "not_authenticated"
+        code = 401 if is_not_authenticated else 404
+        error_code = "not_authenticated" if is_not_authenticated else "not_found"
+        # Optionally log the internal error message for diagnostics.
+        logging.warning("Permission error in api_git_sync_project(%s): %s", project_id, e)
+        return JSONResponse({"error": error_code}, status_code=code)
 
     sub = actor["sub"]
     email = actor["email"]
EOF
@@ -1319,8 +1319,13 @@
except ProjectLockedError as e:
return _project_locked_json_response(e.locked_by_email, e.locked_at)
except PermissionError as e:
code = 401 if str(e) == "not_authenticated" else 404
return JSONResponse({"error": str(e)}, status_code=code)
# Do not expose raw exception messages to clients; map to controlled error codes.
is_not_authenticated = str(e) == "not_authenticated"
code = 401 if is_not_authenticated else 404
error_code = "not_authenticated" if is_not_authenticated else "not_found"
# Optionally log the internal error message for diagnostics.
logging.warning("Permission error in api_git_sync_project(%s): %s", project_id, e)
return JSONResponse({"error": error_code}, status_code=code)

sub = actor["sub"]
email = actor["email"]
Copilot is powered by AI and may make mistakes. Always verify output.
{
"commits": [
{
"sha": c.sha,

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

Copilot Autofix

AI 1 day ago

In general, to fix this kind of issue you should avoid returning raw exception messages or stack traces in API responses. Instead, log the detailed error on the server (including the stack trace and repr(e)), and return a generic, user-safe error message or a stable error code. This preserves debuggability for developers while preventing attackers from learning about internal implementation details.

For this specific case, we should change the except Exception as e: block around the GitLab commit listing call (lines 1601–1614). Instead of including str(e) in the "detail" field of the JSON response, we should log the exception server-side using the existing logging module (already imported at the top of the file) and return a generic error object without any sensitive details. The surrounding code already distinguishes certain GitLab error states above (e.g., "gitlab_error" cases) and uses "gitlab_error" without details in one branch, so returning a simple "git_history_failed" without "detail" is consistent with existing patterns and does not alter control flow.

Concretely:

  • In src/runtimes/ws_server.py, locate the try/except that calls GitLabClient.from_env().list_project_commits.
  • Inside the except Exception as e::
    • Add a log statement such as logging.exception("Failed to list GitLab project commits") to capture the full stack trace on the server.
    • Change the JSONResponse payload so it no longer includes str(e), returning only {"error": "git_history_failed"}.
  • No new imports are needed (the logging module is already imported on line 7).
Suggested changeset 1
src/runtimes/ws_server.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py
--- a/src/runtimes/ws_server.py
+++ b/src/runtimes/ws_server.py
@@ -1607,9 +1607,10 @@
             ref_name=ref_name,
             limit=normalized_limit,
         )
-    except Exception as e:
+    except Exception:
+        logging.exception("Failed to list GitLab project commits")
         return JSONResponse(
-            {"error": "git_history_failed", "detail": str(e)},
+            {"error": "git_history_failed"},
             status_code=502,
         )
 
EOF
@@ -1607,9 +1607,10 @@
ref_name=ref_name,
limit=normalized_limit,
)
except Exception as e:
except Exception:
logging.exception("Failed to list GitLab project commits")
return JSONResponse(
{"error": "git_history_failed", "detail": str(e)},
{"error": "git_history_failed"},
status_code=502,
)

Copilot is powered by AI and may make mistakes. Always verify output.
@@ -1709,13 +2111,74 @@ def _ensure_project_access(request: Request, *, project_id: str):

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

Copilot Autofix

AI 1 day ago

In general, to fix this category of issue you should avoid returning raw exception messages (or stack traces) directly to the client. Instead, log the detailed error server-side (if needed) and send a generic, controlled error code or message in the HTTP response. This ensures developers can still debug, but untrusted clients do not see internals.

For this specific endpoint in src/runtimes/ws_server.py, the problematic code is:

1651:     except PermissionError as e:
1652:         code = 401 if str(e) == "not_authenticated" else 404
1653:         return JSONResponse({"error": str(e)}, status_code=code)

The best minimal fix without changing behavior from the client’s perspective is:

  • Replace returning str(e) with a controlled, non-sensitive error identifier.
  • Since the code already uses the string value of the exception to choose between 401 and 404, we can continue to use that internally but normalize what we send back.
  • A simple approach is to:
    • Map "not_authenticated" to a fixed "not_authenticated" error code.
    • Map all other cases to a generic "not_found" (or similar) error code.
  • Optionally, log the full exception message at server-side using the existing logging facilities (logging.getLogger(__name__) is already likely used earlier in the file). However, since we don’t see the logger here and we must minimize changes, we can just stop exposing str(e) externally.

Concretely, edit the except PermissionError block in api_git_pull_project so it becomes:

    except PermissionError as e:
        msg = str(e)
        code = 401 if msg == "not_authenticated" else 404
        error_code = "not_authenticated" if msg == "not_authenticated" else "not_found"
        return JSONResponse({"error": error_code}, status_code=code)

This keeps the existing semantics (401 for unauthenticated, 404 otherwise) but no longer leaks arbitrary exception text to clients.

Suggested changeset 1
src/runtimes/ws_server.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py
--- a/src/runtimes/ws_server.py
+++ b/src/runtimes/ws_server.py
@@ -1649,8 +1649,10 @@
     except ProjectLockedError as e:
         return _project_locked_json_response(e.locked_by_email, e.locked_at)
     except PermissionError as e:
-        code = 401 if str(e) == "not_authenticated" else 404
-        return JSONResponse({"error": str(e)}, status_code=code)
+        msg = str(e)
+        code = 401 if msg == "not_authenticated" else 404
+        error_code = "not_authenticated" if msg == "not_authenticated" else "not_found"
+        return JSONResponse({"error": error_code}, status_code=code)
 
     sub = actor["sub"]
     email = actor["email"]
EOF
@@ -1649,8 +1649,10 @@
except ProjectLockedError as e:
return _project_locked_json_response(e.locked_by_email, e.locked_at)
except PermissionError as e:
code = 401 if str(e) == "not_authenticated" else 404
return JSONResponse({"error": str(e)}, status_code=code)
msg = str(e)
code = 401 if msg == "not_authenticated" else 404
error_code = "not_authenticated" if msg == "not_authenticated" else "not_found"
return JSONResponse({"error": error_code}, status_code=code)

sub = actor["sub"]
email = actor["email"]
Copilot is powered by AI and may make mistakes. Always verify output.

body: Any
try:
body = await request.json()

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

Copilot Autofix

AI 1 day ago

In general, the fix is to avoid sending raw exception messages back to clients. Instead, send a generic or controlled error code/message, and if needed, log the detailed exception server-side for debugging. This prevents accidental leakage of stack traces or sensitive internal details through error responses.

For this specific handler in src/runtimes/ws_server.py, we should:

  • Keep the logic that chooses 401 vs 404 based on the exception message ("not_authenticated" vs other).
  • Stop returning str(e) to the client.
  • Replace it with a controlled error code string that does not depend on the full exception text, while preserving the 401/404 behavior.
  • Optionally, log the original exception using the existing logging infrastructure (there is already an imported logging module at the top of the file).

The minimal, behavior-preserving change is:

  • In the except PermissionError as e: block, derive an error code like "not_authenticated" or "not_found" from the same condition used to set the status code and return that instead of str(e). That way, clients still get a useful, stable string, but we no longer expose arbitrary exception text.

Concretely:

  • In src/runtimes/ws_server.py, around lines 2542–2544, change:
    • code = 401 if str(e) == "not_authenticated" else 404
    • return JSONResponse({"error": str(e)}, status_code=code)
  • To:
    • Determine error_code based on the same comparison.
    • Optionally log the error.
    • return JSONResponse({"error": error_code}, status_code=code)

No new imports are strictly necessary; logging is already imported if we want to log the error.

Suggested changeset 1
src/runtimes/ws_server.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py
--- a/src/runtimes/ws_server.py
+++ b/src/runtimes/ws_server.py
@@ -2540,8 +2540,9 @@
     except ProjectLockedError as e:
         return _project_locked_json_response(e.locked_by_email, e.locked_at)
     except PermissionError as e:
-        code = 401 if str(e) == "not_authenticated" else 404
-        return JSONResponse({"error": str(e)}, status_code=code)
+        error_code = "not_authenticated" if str(e) == "not_authenticated" else "not_found"
+        code = 401 if error_code == "not_authenticated" else 404
+        return JSONResponse({"error": error_code}, status_code=code)
 
     sub = actor["sub"]
     email = actor["email"]
EOF
@@ -2540,8 +2540,9 @@
except ProjectLockedError as e:
return _project_locked_json_response(e.locked_by_email, e.locked_at)
except PermissionError as e:
code = 401 if str(e) == "not_authenticated" else 404
return JSONResponse({"error": str(e)}, status_code=code)
error_code = "not_authenticated" if str(e) == "not_authenticated" else "not_found"
code = 401 if error_code == "not_authenticated" else 404
return JSONResponse({"error": error_code}, status_code=code)

sub = actor["sub"]
email = actor["email"]
Copilot is powered by AI and may make mistakes. Always verify output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant