From 45487bba3a9f3f3aa80447efdd1c82ea8fabc105 Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Thu, 12 Feb 2026 16:35:02 +0100 Subject: [PATCH 01/16] feat(store): add project_members table and locking columns - Add locked_by_sub and locked_at columns to projects table for session locking - Create project_members table with FK to projects for N:M user-project relationships - Add indexes on user_sub and user_email for membership queries - Update FakeHasuraClient to track table creation in tests --- src/projects/store.py | 19 +++++++++++++++++++ tests/test_projects_store.py | 28 ++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/projects/store.py b/src/projects/store.py index a9b12df..f805e55 100644 --- a/src/projects/store.py +++ b/src/projects/store.py @@ -62,6 +62,8 @@ def ensure_projects_schema(client: HasuraClient) -> None: gitlab_project_id bigint NULL, gitlab_path text NULL, gitlab_web_url text NULL, + locked_by_sub text NULL, + locked_at timestamptz NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz NULL @@ -76,6 +78,23 @@ def ensure_projects_schema(client: HasuraClient) -> None: ADD COLUMN IF NOT EXISTS gitlab_path text NULL; ALTER TABLE amicable_meta.projects ADD COLUMN IF NOT EXISTS gitlab_web_url text NULL; + ALTER TABLE amicable_meta.projects + ADD COLUMN IF NOT EXISTS locked_by_sub text NULL; + ALTER TABLE amicable_meta.projects + ADD COLUMN IF NOT EXISTS locked_at timestamptz NULL; + + CREATE TABLE IF NOT EXISTS amicable_meta.project_members ( + project_id text NOT NULL REFERENCES amicable_meta.projects(project_id) ON DELETE CASCADE, + user_sub text NOT NULL, + user_email text NOT NULL, + added_at timestamptz NOT NULL DEFAULT now(), + added_by_sub text NULL, + PRIMARY KEY (project_id, user_sub) + ); + CREATE INDEX IF NOT EXISTS idx_project_members_user + ON amicable_meta.project_members(user_sub); + CREATE INDEX IF NOT EXISTS idx_project_members_email + ON amicable_meta.project_members(user_email); """.strip() ) _schema_ready = True diff --git a/tests/test_projects_store.py b/tests/test_projects_store.py index 5918007..b9cd6c2 100644 --- a/tests/test_projects_store.py +++ b/tests/test_projects_store.py @@ -21,6 +21,8 @@ class FakeHasuraClient: def __init__(self) -> None: self.projects: dict[str, dict] = {} + self.members: dict[tuple[str, str], dict] = {} # (project_id, user_key) -> member + self.schema_created_tables: dict[str, bool] = {} class _Cfg: source_name = "default" @@ -32,10 +34,13 @@ def run_sql(self, sql: str, *, read_only: bool = False): sql = sql.strip() sql_l = sql.lower() - # Schema creation: ignore. - if sql.lower().startswith("create schema") or sql.lower().startswith( - "create table" - ): + # Schema creation: track tables and ignore. + # Handle multi-statement SQL for schema migration + if "create schema" in sql_l or "create table" in sql_l or "alter table" in sql_l or "create index" in sql_l: + if "create table" in sql_l and "project_members" in sql_l: + self.schema_created_tables["project_members"] = True + if "create table" in sql_l and "amicable_meta.projects" in sql_l: + self.schema_created_tables["projects"] = True return {"result_type": "CommandOk", "result": []} # SELECT by project_id. @@ -287,3 +292,18 @@ def test_ensure_project_for_id_owner_mismatch() -> None: with pytest.raises(PermissionError): ensure_project_for_id(c, owner=owner2, project_id="abc-123") + + +def test_project_members_table_created() -> None: + """Verify project_members table is created during schema migration.""" + from src.projects import store + + # Reset schema state for this test + store._schema_ready = False + + c = FakeHasuraClient() + from src.projects.store import ensure_projects_schema + + ensure_projects_schema(c) + # The fake client should have received CREATE TABLE for project_members + assert c.schema_created_tables.get("project_members") is True From 9647eccf87029df3036023a57a5cf98497d91ec1 Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Thu, 12 Feb 2026 16:36:30 +0100 Subject: [PATCH 02/16] feat(store): add ProjectMember dataclass and CRUD functions - Add ProjectMember dataclass with project_id, user_sub, user_email, added_at, added_by_sub - Add add_project_member() to add members by email (with optional user_sub) - Add list_project_members() to list all members of a project - Add remove_project_member() to remove members (prevents removing last member) - Add is_project_member() to check membership by sub or email - Update create_project() to add creator as first member - Update FakeHasuraClient to handle member INSERT/SELECT/DELETE --- src/projects/store.py | 143 +++++++++++++++++++++++++++++++++++ tests/test_projects_store.py | 108 +++++++++++++++++++++++++- 2 files changed, 250 insertions(+), 1 deletion(-) diff --git a/src/projects/store.py b/src/projects/store.py index f805e55..256b875 100644 --- a/src/projects/store.py +++ b/src/projects/store.py @@ -122,6 +122,15 @@ class Project: updated_at: str | None = None +@dataclass(frozen=True) +class ProjectMember: + project_id: str + user_sub: str | None # None if invited by email but not yet logged in + user_email: str + added_at: str | None = None + added_by_sub: str | None = None + + def _tuples_to_dicts(res: dict[str, Any]) -> list[dict[str, Any]]: rows = res.get("result") if not isinstance(rows, list) or len(rows) < 2: @@ -517,6 +526,14 @@ def create_project( ) p = _get_project_by_id_any_owner(client, project_id=project_id) if p and p.owner_sub == owner.sub: + # Add creator as first member + add_project_member( + client, + project_id=project_id, + user_sub=owner.sub, + user_email=owner.email, + added_by_sub=None, + ) return p raise RuntimeError("failed to allocate unique project slug") @@ -632,3 +649,129 @@ def hard_delete_project_row( WHERE project_id = {_sql_str(project_id)} AND owner_sub = {_sql_str(owner.sub)}; """.strip() ) + + +# --------------------------------------------------------------------------- +# Project Members +# --------------------------------------------------------------------------- + + +def _get_member_by_email( + client: HasuraClient, *, project_id: str, user_email: str +) -> ProjectMember | None: + res = client.run_sql( + f""" + SELECT project_id, user_sub, user_email, added_at, added_by_sub + FROM amicable_meta.project_members + WHERE project_id = {_sql_str(project_id)} AND user_email = {_sql_str(user_email.lower())} + LIMIT 1; + """.strip(), + read_only=True, + ) + rows = _tuples_to_dicts(res) + if not rows: + return None + r = rows[0] + return ProjectMember( + project_id=str(r["project_id"]), + user_sub=str(r["user_sub"]) if r.get("user_sub") else None, + user_email=str(r["user_email"]), + added_at=str(r.get("added_at")) if r.get("added_at") else None, + added_by_sub=str(r.get("added_by_sub")) if r.get("added_by_sub") else None, + ) + + +def add_project_member( + client: HasuraClient, + *, + project_id: str, + user_email: str, + user_sub: str | None = None, + added_by_sub: str | None = None, +) -> ProjectMember: + """Add a member to a project. If user_sub is None, they'll be matched on first login.""" + ensure_projects_schema(client) + user_email = user_email.strip().lower() + + # Check if already a member by email + existing = _get_member_by_email(client, project_id=project_id, user_email=user_email) + if existing: + return existing + + sub_sql = _sql_str(user_sub) if user_sub else "NULL" + added_by_sql = _sql_str(added_by_sub) if added_by_sub else "NULL" + + client.run_sql( + f""" + INSERT INTO amicable_meta.project_members (project_id, user_sub, user_email, added_by_sub) + VALUES ({_sql_str(project_id)}, {sub_sql}, {_sql_str(user_email)}, {added_by_sql}) + ON CONFLICT (project_id, user_sub) DO NOTHING; + """.strip() + ) + return ProjectMember( + project_id=project_id, + user_sub=user_sub, + user_email=user_email, + added_by_sub=added_by_sub, + ) + + +def list_project_members(client: HasuraClient, *, project_id: str) -> list[ProjectMember]: + """List all members of a project.""" + ensure_projects_schema(client) + res = client.run_sql( + f""" + SELECT project_id, user_sub, user_email, added_at, added_by_sub + FROM amicable_meta.project_members + WHERE project_id = {_sql_str(project_id)} + ORDER BY added_at ASC; + """.strip(), + read_only=True, + ) + out: list[ProjectMember] = [] + for r in _tuples_to_dicts(res): + out.append( + ProjectMember( + project_id=str(r["project_id"]), + user_sub=str(r["user_sub"]) if r.get("user_sub") else None, + user_email=str(r["user_email"]), + added_at=str(r.get("added_at")) if r.get("added_at") else None, + added_by_sub=str(r.get("added_by_sub")) if r.get("added_by_sub") else None, + ) + ) + return out + + +def remove_project_member( + client: HasuraClient, *, project_id: str, user_sub: str +) -> bool: + """Remove a member from a project. Returns False if they were the last member.""" + ensure_projects_schema(client) + members = list_project_members(client, project_id=project_id) + if len(members) <= 1: + return False + client.run_sql( + f""" + DELETE FROM amicable_meta.project_members + WHERE project_id = {_sql_str(project_id)} AND user_sub = {_sql_str(user_sub)}; + """.strip() + ) + return True + + +def is_project_member( + client: HasuraClient, *, project_id: str, user_sub: str, user_email: str +) -> bool: + """Check if a user is a member of a project (by sub or email).""" + ensure_projects_schema(client) + res = client.run_sql( + f""" + SELECT 1 FROM amicable_meta.project_members + WHERE project_id = {_sql_str(project_id)} + AND (user_sub = {_sql_str(user_sub)} OR user_email = {_sql_str(user_email.lower())}) + LIMIT 1; + """.strip(), + read_only=True, + ) + rows = _tuples_to_dicts(res) + return bool(rows) diff --git a/tests/test_projects_store.py b/tests/test_projects_store.py index b9cd6c2..f7e353e 100644 --- a/tests/test_projects_store.py +++ b/tests/test_projects_store.py @@ -229,7 +229,7 @@ def run_sql(self, sql: str, *, read_only: bool = False): row["updated_at"] = "t_del" return {"result_type": "CommandOk", "result": []} - # DELETE. + # DELETE project. if sql_l.startswith("delete from amicable_meta.projects"): pid = re.search(r"where project_id\s*=\s*'([^']+)'", sql, flags=re.I).group( 1 @@ -240,6 +240,89 @@ def run_sql(self, sql: str, *, read_only: bool = False): self.projects.pop(pid, None) return {"result_type": "CommandOk", "result": []} + # --------------------------------------------------------------- + # Project Members + # --------------------------------------------------------------- + + # INSERT member + if sql_l.startswith("insert into amicable_meta.project_members"): + vals = re.search(r"values\s*\((.*)\)\s*on conflict", sql, flags=re.I | re.S) + if vals: + parts = [p.strip() for p in vals.group(1).split(",")] + pid = parts[0].strip("'") + user_sub = parts[1].strip("'") if parts[1].strip() != "NULL" else None + user_email = parts[2].strip("'").lower() + added_by = ( + parts[3].strip("'") + if len(parts) > 3 and parts[3].strip() != "NULL" + else None + ) + # Use user_sub as key if available, else email + key = (pid, user_sub or user_email) + if key not in self.members: + self.members[key] = { + "project_id": pid, + "user_sub": user_sub, + "user_email": user_email, + "added_at": "t0", + "added_by_sub": added_by, + } + return {"result_type": "CommandOk", "result": []} + + # SELECT members by project_id + if "from amicable_meta.project_members" in sql_l and sql_l.startswith("select"): + pid_match = re.search(r"where project_id\s*=\s*'([^']+)'", sql, flags=re.I) + if pid_match: + pid = pid_match.group(1) + # Check for email filter (for _get_member_by_email) + email_match = re.search( + r"and user_email\s*=\s*'([^']+)'", sql, flags=re.I + ) + if email_match: + email = email_match.group(1).lower() + rows = [ + m + for m in self.members.values() + if m["project_id"] == pid and m["user_email"] == email + ] + else: + rows = [m for m in self.members.values() if m["project_id"] == pid] + + # SELECT 1 for is_project_member + if sql_l.startswith("select 1"): + if rows: + return {"result_type": "TuplesOk", "result": [["1"], [1]]} + return {"result_type": "TuplesOk", "result": [["1"]]} + + header = [ + "project_id", + "user_sub", + "user_email", + "added_at", + "added_by_sub", + ] + out = [header] + for m in rows: + out.append( + [ + m["project_id"], + m["user_sub"], + m["user_email"], + m["added_at"], + m["added_by_sub"], + ] + ) + return {"result_type": "TuplesOk", "result": out} + + # DELETE member + if sql_l.startswith("delete from amicable_meta.project_members"): + pid_match = re.search(r"where project_id\s*=\s*'([^']+)'", sql, flags=re.I) + sub_match = re.search(r"and user_sub\s*=\s*'([^']+)'", sql, flags=re.I) + if pid_match and sub_match: + key = (pid_match.group(1), sub_match.group(1)) + self.members.pop(key, None) + return {"result_type": "CommandOk", "result": []} + raise AssertionError(f"Unhandled SQL in test fake: {sql}") def metadata(self, payload: dict): @@ -307,3 +390,26 @@ def test_project_members_table_created() -> None: ensure_projects_schema(c) # The fake client should have received CREATE TABLE for project_members assert c.schema_created_tables.get("project_members") is True + + +def test_add_and_list_project_members() -> None: + """Test adding and listing project members.""" + c = FakeHasuraClient() + owner = ProjectOwner(sub="u1", email="u1@example.com") + p = create_project(c, owner=owner, name="Shared Project") + + from src.projects.store import add_project_member, list_project_members + + # Creator should already be a member + members = list_project_members(c, project_id=p.project_id) + assert len(members) == 1 + assert members[0].user_sub == "u1" + + # Add another member + add_project_member( + c, project_id=p.project_id, user_email="u2@example.com", added_by_sub="u1" + ) + members = list_project_members(c, project_id=p.project_id) + assert len(members) == 2 + emails = {m.user_email for m in members} + assert emails == {"u1@example.com", "u2@example.com"} From b8046cfb6832f44df7795f16ecc4213c5465dd33 Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Thu, 12 Feb 2026 16:40:04 +0100 Subject: [PATCH 03/16] feat(store): change access control from ownership to membership - Update get_project_by_id() to check is_project_member() instead of owner_sub - Update get_project_by_slug() to check membership - Update list_projects() to use JOIN with project_members table - Users can now access projects they are members of, not just projects they own - Update FakeHasuraClient with proper membership JOIN and is_project_member handlers --- src/projects/store.py | 25 +++++--- tests/test_projects_store.py | 110 ++++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 11 deletions(-) diff --git a/src/projects/store.py b/src/projects/store.py index 256b875..b1fe33b 100644 --- a/src/projects/store.py +++ b/src/projects/store.py @@ -260,7 +260,9 @@ def get_project_by_id( p = _get_project_by_id_any_owner(client, project_id=project_id) if not p: return None - if p.owner_sub != owner.sub: + if not is_project_member( + client, project_id=project_id, user_sub=owner.sub, user_email=owner.email + ): return None return p @@ -284,10 +286,13 @@ def get_project_by_slug( if not rows: return None r = rows[0] - if str(r.get("owner_sub")) != owner.sub: + project_id = str(r["project_id"]) + if not is_project_member( + client, project_id=project_id, user_sub=owner.sub, user_email=owner.email + ): return None return Project( - project_id=str(r["project_id"]), + project_id=project_id, owner_sub=str(r["owner_sub"]), owner_email=str(r["owner_email"]), name=str(r["name"]), @@ -385,12 +390,14 @@ def list_projects(client: HasuraClient, *, owner: ProjectOwner) -> list[Project] ensure_projects_schema(client) res = client.run_sql( f""" - SELECT project_id, owner_sub, owner_email, name, slug, sandbox_id, template_id, - gitlab_project_id, gitlab_path, gitlab_web_url, - created_at, updated_at - FROM amicable_meta.projects - WHERE owner_sub = {_sql_str(owner.sub)} AND deleted_at IS NULL - ORDER BY updated_at DESC; + SELECT p.project_id, p.owner_sub, p.owner_email, p.name, p.slug, p.sandbox_id, p.template_id, + p.gitlab_project_id, p.gitlab_path, p.gitlab_web_url, + p.created_at, p.updated_at + FROM amicable_meta.projects p + JOIN amicable_meta.project_members pm ON pm.project_id = p.project_id + WHERE (pm.user_sub = {_sql_str(owner.sub)} OR pm.user_email = {_sql_str(owner.email.lower())}) + AND p.deleted_at IS NULL + ORDER BY p.updated_at DESC; """.strip(), read_only=True, ) diff --git a/tests/test_projects_store.py b/tests/test_projects_store.py index f7e353e..c4ae572 100644 --- a/tests/test_projects_store.py +++ b/tests/test_projects_store.py @@ -120,7 +120,60 @@ def run_sql(self, sql: str, *, read_only: bool = False): ] return {"result_type": "TuplesOk", "result": [header, data]} - # List by owner_sub. + # List projects with JOIN on members (new membership-based query) + if "join amicable_meta.project_members" in sql_l and "order by" in sql_l: + sub_match = re.search(r"pm\.user_sub\s*=\s*'([^']+)'", sql, flags=re.I) + email_match = re.search(r"pm\.user_email\s*=\s*'([^']+)'", sql, flags=re.I) + sub = sub_match.group(1) if sub_match else None + email = email_match.group(1).lower() if email_match else None + + # Find projects where user is a member + member_project_ids = set() + for m in self.members.values(): + if m.get("user_sub") == sub or m.get("user_email") == email: + member_project_ids.add(m["project_id"]) + + rows = [ + p + for p in self.projects.values() + if p["project_id"] in member_project_ids and not p.get("deleted_at") + ] + + header = [ + "project_id", + "owner_sub", + "owner_email", + "name", + "slug", + "sandbox_id", + "template_id", + "gitlab_project_id", + "gitlab_path", + "gitlab_web_url", + "created_at", + "updated_at", + ] + out = [header] + for r in rows: + out.append( + [ + r["project_id"], + r["owner_sub"], + r["owner_email"], + r["name"], + r["slug"], + r.get("sandbox_id"), + r.get("template_id"), + r.get("gitlab_project_id"), + r.get("gitlab_path"), + r.get("gitlab_web_url"), + r.get("created_at"), + r.get("updated_at"), + ] + ) + return {"result_type": "TuplesOk", "result": out} + + # List by owner_sub (legacy - but still needed for backward compatibility in tests). m = re.search(r"where owner_sub\s*=\s*'([^']+)'", sql, flags=re.I) if m and "order by updated_at" in sql_l: sub = m.group(1) @@ -274,6 +327,27 @@ def run_sql(self, sql: str, *, read_only: bool = False): pid_match = re.search(r"where project_id\s*=\s*'([^']+)'", sql, flags=re.I) if pid_match: pid = pid_match.group(1) + + # Check for is_project_member query (SELECT 1 with user_sub/email OR) + if sql_l.startswith("select 1") and "(user_sub" in sql_l: + # Parse the user_sub and user_email from the OR clause + sub_match = re.search(r"user_sub\s*=\s*'([^']+)'", sql, flags=re.I) + email_or_match = re.search( + r"or user_email\s*=\s*'([^']+)'", sql, flags=re.I + ) + user_sub = sub_match.group(1) if sub_match else None + user_email = email_or_match.group(1).lower() if email_or_match else None + + # Check if user is a member (by sub or email) + is_member = any( + m["project_id"] == pid + and (m.get("user_sub") == user_sub or m.get("user_email") == user_email) + for m in self.members.values() + ) + if is_member: + return {"result_type": "TuplesOk", "result": [["1"], [1]]} + return {"result_type": "TuplesOk", "result": [["1"]]} + # Check for email filter (for _get_member_by_email) email_match = re.search( r"and user_email\s*=\s*'([^']+)'", sql, flags=re.I @@ -288,7 +362,7 @@ def run_sql(self, sql: str, *, read_only: bool = False): else: rows = [m for m in self.members.values() if m["project_id"] == pid] - # SELECT 1 for is_project_member + # SELECT 1 without complex filter (simple membership check) if sql_l.startswith("select 1"): if rows: return {"result_type": "TuplesOk", "result": [["1"], [1]]} @@ -413,3 +487,35 @@ def test_add_and_list_project_members() -> None: assert len(members) == 2 emails = {m.user_email for m in members} assert emails == {"u1@example.com", "u2@example.com"} + + +def test_shared_project_access() -> None: + """Users can access projects they're members of, even if not the creator.""" + c = FakeHasuraClient() + owner = ProjectOwner(sub="u1", email="u1@example.com") + other = ProjectOwner(sub="u2", email="u2@example.com") + + p = create_project(c, owner=owner, name="Shared Project") + + # Other user cannot access yet + assert get_project_by_id(c, owner=other, project_id=p.project_id) is None + + # Add other as member + from src.projects.store import add_project_member + + add_project_member( + c, + project_id=p.project_id, + user_sub="u2", + user_email="u2@example.com", + added_by_sub="u1", + ) + + # Now other can access + got = get_project_by_id(c, owner=other, project_id=p.project_id) + assert got is not None + assert got.project_id == p.project_id + + # And it appears in their list + lst = list_projects(c, owner=other) + assert any(proj.project_id == p.project_id for proj in lst) From 44bcad1aa87df8a34976c7e51add3397def956e2 Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Thu, 12 Feb 2026 16:42:11 +0100 Subject: [PATCH 04/16] feat(store): add session locking functions - Add ProjectLock dataclass with project_id, locked_by_sub, locked_by_email, locked_at - Add get_project_lock() to check current lock status - Add acquire_project_lock() to acquire lock (with optional force=True for takeover) - Add release_project_lock() to release lock if held by user - Update FakeHasuraClient with lock query handlers --- src/projects/store.py | 91 +++++++++++++++++++++++++++++ tests/test_projects_store.py | 107 +++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/src/projects/store.py b/src/projects/store.py index b1fe33b..a4f2189 100644 --- a/src/projects/store.py +++ b/src/projects/store.py @@ -131,6 +131,14 @@ class ProjectMember: added_by_sub: str | None = None +@dataclass(frozen=True) +class ProjectLock: + project_id: str + locked_by_sub: str + locked_by_email: str + locked_at: str + + def _tuples_to_dicts(res: dict[str, Any]) -> list[dict[str, Any]]: rows = res.get("result") if not isinstance(rows, list) or len(rows) < 2: @@ -782,3 +790,86 @@ def is_project_member( ) rows = _tuples_to_dicts(res) return bool(rows) + + +# --------------------------------------------------------------------------- +# Session Locking +# --------------------------------------------------------------------------- + + +def get_project_lock(client: HasuraClient, *, project_id: str) -> ProjectLock | None: + """Get current lock info for a project.""" + ensure_projects_schema(client) + res = client.run_sql( + f""" + SELECT locked_by_sub, locked_at + FROM amicable_meta.projects + WHERE project_id = {_sql_str(project_id)} AND deleted_at IS NULL AND locked_by_sub IS NOT NULL + LIMIT 1; + """.strip(), + read_only=True, + ) + rows = _tuples_to_dicts(res) + if not rows or not rows[0].get("locked_by_sub"): + return None + r = rows[0] + # Get email from members table + email_res = client.run_sql( + f""" + SELECT user_email FROM amicable_meta.project_members + WHERE project_id = {_sql_str(project_id)} AND user_sub = {_sql_str(str(r["locked_by_sub"]))} + LIMIT 1; + """.strip(), + read_only=True, + ) + email_rows = _tuples_to_dicts(email_res) + email = str(email_rows[0]["user_email"]) if email_rows else "" + return ProjectLock( + project_id=project_id, + locked_by_sub=str(r["locked_by_sub"]), + locked_by_email=email, + locked_at=str(r.get("locked_at") or ""), + ) + + +def acquire_project_lock( + client: HasuraClient, + *, + project_id: str, + user_sub: str, + user_email: str, + force: bool = False, +) -> ProjectLock | None: + """Try to acquire lock. Returns None if locked by someone else (unless force=True).""" + ensure_projects_schema(client) + current = get_project_lock(client, project_id=project_id) + if current and current.locked_by_sub != user_sub and not force: + return None + + client.run_sql( + f""" + UPDATE amicable_meta.projects + SET locked_by_sub = {_sql_str(user_sub)}, locked_at = now(), updated_at = now() + WHERE project_id = {_sql_str(project_id)} AND deleted_at IS NULL; + """.strip() + ) + return ProjectLock( + project_id=project_id, + locked_by_sub=user_sub, + locked_by_email=user_email, + locked_at="now", + ) + + +def release_project_lock( + client: HasuraClient, *, project_id: str, user_sub: str +) -> None: + """Release lock if held by user_sub.""" + ensure_projects_schema(client) + client.run_sql( + f""" + UPDATE amicable_meta.projects + SET locked_by_sub = NULL, locked_at = NULL, updated_at = now() + WHERE project_id = {_sql_str(project_id)} AND locked_by_sub = {_sql_str(user_sub)} AND deleted_at IS NULL; + """.strip() + ) diff --git a/tests/test_projects_store.py b/tests/test_projects_store.py index c4ae572..b251cd3 100644 --- a/tests/test_projects_store.py +++ b/tests/test_projects_store.py @@ -43,6 +43,60 @@ def run_sql(self, sql: str, *, read_only: bool = False): self.schema_created_tables["projects"] = True return {"result_type": "CommandOk", "result": []} + # --------------------------------------------------------------- + # Session Locking (must come before generic project SELECT) + # --------------------------------------------------------------- + + # SELECT lock info (get_project_lock) - check for "locked_by_sub is not null" + if ( + "locked_by_sub" in sql_l + and "locked_by_sub is not null" in sql_l + and "from amicable_meta.projects" in sql_l + and sql_l.startswith("select") + ): + pid_match = re.search(r"where project_id\s*=\s*'([^']+)'", sql, flags=re.I) + if pid_match: + row = self.projects.get(pid_match.group(1)) + if row and not row.get("deleted_at") and row.get("locked_by_sub"): + return { + "result_type": "TuplesOk", + "result": [ + ["locked_by_sub", "locked_at"], + [row["locked_by_sub"], row.get("locked_at")], + ], + } + return { + "result_type": "TuplesOk", + "result": [["locked_by_sub", "locked_at"]], + } + + # UPDATE lock (acquire/release) - check for "set locked_by_sub" + if ( + sql_l.startswith("update amicable_meta.projects") + and "set locked_by_sub" in sql_l + ): + pid_match = re.search(r"where project_id\s*=\s*'([^']+)'", sql, flags=re.I) + if pid_match: + row = self.projects.get(pid_match.group(1)) + if row and not row.get("deleted_at"): + if "locked_by_sub = null" in sql_l.lower(): + # Release - check user matches + sub_match = re.search( + r"and locked_by_sub\s*=\s*'([^']+)'", sql, flags=re.I + ) + if sub_match and row.get("locked_by_sub") == sub_match.group(1): + row["locked_by_sub"] = None + row["locked_at"] = None + else: + # Acquire + sub_match = re.search( + r"set locked_by_sub\s*=\s*'([^']+)'", sql, flags=re.I + ) + if sub_match: + row["locked_by_sub"] = sub_match.group(1) + row["locked_at"] = "now" + return {"result_type": "CommandOk", "result": []} + # SELECT by project_id. m = re.search(r"where project_id\s*=\s*'([^']+)'", sql, flags=re.I) if m and sql_l.startswith("select") and "from amicable_meta.projects" in sql_l: @@ -519,3 +573,56 @@ def test_shared_project_access() -> None: # And it appears in their list lst = list_projects(c, owner=other) assert any(proj.project_id == p.project_id for proj in lst) + + +def test_project_locking() -> None: + """Test session locking prevents concurrent access.""" + c = FakeHasuraClient() + owner1 = ProjectOwner(sub="u1", email="u1@example.com") + owner2 = ProjectOwner(sub="u2", email="u2@example.com") + + p = create_project(c, owner=owner1, name="Lockable") + + # Add u2 as member + from src.projects.store import ( + add_project_member, + acquire_project_lock, + get_project_lock, + release_project_lock, + ) + + add_project_member( + c, + project_id=p.project_id, + user_sub="u2", + user_email="u2@example.com", + added_by_sub="u1", + ) + + # u1 acquires lock + lock = acquire_project_lock( + c, project_id=p.project_id, user_sub="u1", user_email="u1@example.com" + ) + assert lock is not None + assert lock.locked_by_sub == "u1" + + # u2 cannot acquire (without force) + lock2 = acquire_project_lock( + c, project_id=p.project_id, user_sub="u2", user_email="u2@example.com" + ) + assert lock2 is None + + # Check who has lock + current = get_project_lock(c, project_id=p.project_id) + assert current is not None + assert current.locked_by_sub == "u1" + + # u1 releases + release_project_lock(c, project_id=p.project_id, user_sub="u1") + + # Now u2 can acquire + lock3 = acquire_project_lock( + c, project_id=p.project_id, user_sub="u2", user_email="u2@example.com" + ) + assert lock3 is not None + assert lock3.locked_by_sub == "u2" From c4b12ae756a94471842e59aeccf8a45dfc3ad1e8 Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Thu, 12 Feb 2026 16:42:43 +0100 Subject: [PATCH 05/16] test(store): add force-claim locking test Verify that acquire_project_lock() with force=True allows taking over a lock held by another user. --- tests/test_projects_store.py | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_projects_store.py b/tests/test_projects_store.py index b251cd3..0ad9786 100644 --- a/tests/test_projects_store.py +++ b/tests/test_projects_store.py @@ -626,3 +626,42 @@ def test_project_locking() -> None: ) assert lock3 is not None assert lock3.locked_by_sub == "u2" + + +def test_project_lock_force_claim() -> None: + """Test that force=True allows taking over a lock.""" + c = FakeHasuraClient() + owner1 = ProjectOwner(sub="u1", email="u1@example.com") + + p = create_project(c, owner=owner1, name="Forceable") + + from src.projects.store import add_project_member, acquire_project_lock, get_project_lock + + add_project_member( + c, + project_id=p.project_id, + user_sub="u2", + user_email="u2@example.com", + added_by_sub="u1", + ) + + # u1 acquires lock + acquire_project_lock( + c, project_id=p.project_id, user_sub="u1", user_email="u1@example.com" + ) + + # u2 force-claims + lock = acquire_project_lock( + c, + project_id=p.project_id, + user_sub="u2", + user_email="u2@example.com", + force=True, + ) + assert lock is not None + assert lock.locked_by_sub == "u2" + + # Verify lock is now held by u2 + current = get_project_lock(c, project_id=p.project_id) + assert current is not None + assert current.locked_by_sub == "u2" From d30ca04de08b90bcc6cfdb0a550e8102a30bf44b Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Thu, 12 Feb 2026 16:59:20 +0100 Subject: [PATCH 06/16] fix(store): atomic locking, nullable user_sub, migration for existing projects - Fix race condition in acquire_project_lock with atomic conditional UPDATE - Change project_members PK to (project_id, user_email) for email-only invites - Add migration SQL to add existing project owners as members - Update ensure_project_for_id to check membership instead of ownership - Update FakeHasuraClient to simulate atomic conditional UPDATE --- src/projects/store.py | 90 +++++++++++++++++++++++++++--------- tests/test_projects_store.py | 18 ++++++-- 2 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src/projects/store.py b/src/projects/store.py index a4f2189..2912a8d 100644 --- a/src/projects/store.py +++ b/src/projects/store.py @@ -85,16 +85,27 @@ def ensure_projects_schema(client: HasuraClient) -> None: CREATE TABLE IF NOT EXISTS amicable_meta.project_members ( project_id text NOT NULL REFERENCES amicable_meta.projects(project_id) ON DELETE CASCADE, - user_sub text NOT NULL, + user_sub text NULL, user_email text NOT NULL, added_at timestamptz NOT NULL DEFAULT now(), added_by_sub text NULL, - PRIMARY KEY (project_id, user_sub) + PRIMARY KEY (project_id, user_email) ); CREATE INDEX IF NOT EXISTS idx_project_members_user ON amicable_meta.project_members(user_sub); CREATE INDEX IF NOT EXISTS idx_project_members_email ON amicable_meta.project_members(user_email); + + -- Migration: add existing project owners as members + INSERT INTO amicable_meta.project_members (project_id, user_sub, user_email, added_at) + SELECT p.project_id, p.owner_sub, p.owner_email, p.created_at + FROM amicable_meta.projects p + WHERE p.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM amicable_meta.project_members pm + WHERE pm.project_id = p.project_id + ) + ON CONFLICT DO NOTHING; """.strip() ) _schema_ready = True @@ -561,8 +572,11 @@ def ensure_project_for_id( ensure_projects_schema(client) existing = _get_project_by_id_any_owner(client, project_id=project_id) if existing: - if existing.owner_sub != owner.sub: - raise PermissionError("project belongs to a different user") + # Check membership instead of ownership + if not is_project_member( + client, project_id=project_id, user_sub=owner.sub, user_email=owner.email + ): + raise PermissionError("not a project member") return existing # If a project row exists but was soft-deleted, resurrect it instead of failing @@ -582,6 +596,14 @@ def ensure_project_for_id( ) revived = _get_project_by_id_any_owner(client, project_id=project_id) if revived: + # Ensure member record exists after resurrection + add_project_member( + client, + project_id=project_id, + user_sub=owner.sub, + user_email=owner.email, + added_by_sub=None, + ) return revived short = project_id.replace("-", "")[:8] @@ -603,6 +625,14 @@ def ensure_project_for_id( ) created = _get_project_by_id_any_owner(client, project_id=project_id) if created and created.owner_sub == owner.sub: + # Add creator as first member + add_project_member( + client, + project_id=project_id, + user_sub=owner.sub, + user_email=owner.email, + added_by_sub=None, + ) return created raise RuntimeError("failed to auto-create project") @@ -720,7 +750,7 @@ def add_project_member( f""" INSERT INTO amicable_meta.project_members (project_id, user_sub, user_email, added_by_sub) VALUES ({_sql_str(project_id)}, {sub_sql}, {_sql_str(user_email)}, {added_by_sql}) - ON CONFLICT (project_id, user_sub) DO NOTHING; + ON CONFLICT (project_id, user_email) DO UPDATE SET user_sub = COALESCE(EXCLUDED.user_sub, amicable_meta.project_members.user_sub); """.strip() ) return ProjectMember( @@ -840,25 +870,41 @@ def acquire_project_lock( user_email: str, force: bool = False, ) -> ProjectLock | None: - """Try to acquire lock. Returns None if locked by someone else (unless force=True).""" + """Try to acquire lock atomically. Returns None if locked by someone else (unless force=True).""" ensure_projects_schema(client) - current = get_project_lock(client, project_id=project_id) - if current and current.locked_by_sub != user_sub and not force: - return None - client.run_sql( - f""" - UPDATE amicable_meta.projects - SET locked_by_sub = {_sql_str(user_sub)}, locked_at = now(), updated_at = now() - WHERE project_id = {_sql_str(project_id)} AND deleted_at IS NULL; - """.strip() - ) - return ProjectLock( - project_id=project_id, - locked_by_sub=user_sub, - locked_by_email=user_email, - locked_at="now", - ) + # Atomic conditional update - only acquire if unlocked, held by self, or force + if force: + # Force always succeeds + client.run_sql( + f""" + UPDATE amicable_meta.projects + SET locked_by_sub = {_sql_str(user_sub)}, locked_at = now(), updated_at = now() + WHERE project_id = {_sql_str(project_id)} AND deleted_at IS NULL; + """.strip() + ) + else: + # Only acquire if unlocked or already held by this user + client.run_sql( + f""" + UPDATE amicable_meta.projects + SET locked_by_sub = {_sql_str(user_sub)}, locked_at = now(), updated_at = now() + WHERE project_id = {_sql_str(project_id)} + AND deleted_at IS NULL + AND (locked_by_sub IS NULL OR locked_by_sub = {_sql_str(user_sub)}); + """.strip() + ) + + # Check if we actually got the lock + lock = get_project_lock(client, project_id=project_id) + if lock and lock.locked_by_sub == user_sub: + return ProjectLock( + project_id=project_id, + locked_by_sub=user_sub, + locked_by_email=user_email, + locked_at=lock.locked_at, + ) + return None def release_project_lock( diff --git a/tests/test_projects_store.py b/tests/test_projects_store.py index 0ad9786..5148c63 100644 --- a/tests/test_projects_store.py +++ b/tests/test_projects_store.py @@ -88,13 +88,25 @@ def run_sql(self, sql: str, *, read_only: bool = False): row["locked_by_sub"] = None row["locked_at"] = None else: - # Acquire + # Acquire - extract the new user_sub sub_match = re.search( r"set locked_by_sub\s*=\s*'([^']+)'", sql, flags=re.I ) if sub_match: - row["locked_by_sub"] = sub_match.group(1) - row["locked_at"] = "now" + new_sub = sub_match.group(1) + # Check for atomic conditional update pattern: + # (locked_by_sub IS NULL OR locked_by_sub = 'user') + if "locked_by_sub is null or locked_by_sub" in sql_l: + # Conditional acquire - only if unlocked or held by same user + current_holder = row.get("locked_by_sub") + if current_holder is None or current_holder == new_sub: + row["locked_by_sub"] = new_sub + row["locked_at"] = "now" + # else: don't update (someone else holds it) + else: + # Unconditional (force) acquire + row["locked_by_sub"] = new_sub + row["locked_at"] = "now" return {"result_type": "CommandOk", "result": []} # SELECT by project_id. From be37e780a741d2ca98cf8c78e7a64cdda8400688 Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Thu, 12 Feb 2026 17:19:46 +0100 Subject: [PATCH 07/16] feat(api): add member management endpoints Add GET/POST/DELETE /api/projects/{id}/members endpoints for managing project members. These allow listing members, adding members by email, and removing members by user_sub. Co-Authored-By: Claude Opus 4.5 --- src/runtimes/ws_server.py | 118 ++++++++++++++++++++++++++++++++++++ tests/test_ws_member_api.py | 40 ++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 tests/test_ws_member_api.py diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py index 8f72bf0..2c4a6bb 100644 --- a/src/runtimes/ws_server.py +++ b/src/runtimes/ws_server.py @@ -1116,6 +1116,124 @@ def _cleanup() -> None: return JSONResponse({"status": "deleting"}, status_code=202, background=bg) +# --------------------------------------------------------------------------- +# Project Members API +# --------------------------------------------------------------------------- + + +@app.get("/api/projects/{project_id}/members") +async def api_list_project_members(project_id: str, request: Request) -> JSONResponse: + """List all members of a project.""" + _require_hasura() + try: + sub, email = _get_owner_from_request(request) + except PermissionError: + return JSONResponse({"error": "not_authenticated"}, status_code=401) + + from src.db.provisioning import hasura_client_from_env + from src.projects.store import ProjectOwner, get_project_by_id, list_project_members + + def _list_sync(): + client = hasura_client_from_env() + owner = ProjectOwner(sub=sub, email=email) + project = get_project_by_id(client, owner=owner, project_id=project_id) + if not project: + return None + return list_project_members(client, project_id=project_id) + + members = await asyncio.to_thread(_list_sync) + if members is None: + return JSONResponse({"error": "not_found"}, status_code=404) + + return JSONResponse({ + "members": [ + { + "user_sub": m.user_sub, + "user_email": m.user_email, + "added_at": m.added_at, + } + for m in members + ] + }) + + +@app.post("/api/projects/{project_id}/members") +async def api_add_project_member(project_id: str, request: Request) -> JSONResponse: + """Add a member to a project by email.""" + _require_hasura() + try: + sub, email = _get_owner_from_request(request) + except PermissionError: + return JSONResponse({"error": "not_authenticated"}, status_code=401) + + body: Any + try: + body = await request.json() + except Exception: + body = {} + + new_email = str(body.get("email") or "").strip().lower() + if not new_email or "@" not in new_email: + return JSONResponse({"error": "invalid_email"}, status_code=400) + + from src.db.provisioning import hasura_client_from_env + from src.projects.store import ProjectOwner, add_project_member, get_project_by_id + + def _add_sync(): + client = hasura_client_from_env() + owner = ProjectOwner(sub=sub, email=email) + project = get_project_by_id(client, owner=owner, project_id=project_id) + if not project: + return None + return add_project_member( + client, + project_id=project_id, + user_email=new_email, + added_by_sub=sub, + ) + + member = await asyncio.to_thread(_add_sync) + if member is None: + return JSONResponse({"error": "not_found"}, status_code=404) + + return JSONResponse({ + "user_email": member.user_email, + "added_at": member.added_at, + }, status_code=201) + + +@app.delete("/api/projects/{project_id}/members/{user_sub}") +async def api_remove_project_member( + project_id: str, user_sub: str, request: Request +) -> JSONResponse: + """Remove a member from a project.""" + _require_hasura() + try: + sub, email = _get_owner_from_request(request) + except PermissionError: + return JSONResponse({"error": "not_authenticated"}, status_code=401) + + from src.db.provisioning import hasura_client_from_env + from src.projects.store import ProjectOwner, get_project_by_id, remove_project_member + + def _remove_sync(): + client = hasura_client_from_env() + owner = ProjectOwner(sub=sub, email=email) + project = get_project_by_id(client, owner=owner, project_id=project_id) + if not project: + return "not_found" + success = remove_project_member(client, project_id=project_id, user_sub=user_sub) + return "ok" if success else "last_member" + + result = await asyncio.to_thread(_remove_sync) + if result == "not_found": + return JSONResponse({"error": "not_found"}, status_code=404) + if result == "last_member": + return JSONResponse({"error": "cannot_remove_last_member"}, status_code=400) + + return JSONResponse({"ok": True}) + + def _ensure_project_access(request: Request, *, project_id: str): """Return the project if the request is allowed to access it, else raise PermissionError.""" _require_hasura() diff --git a/tests/test_ws_member_api.py b/tests/test_ws_member_api.py new file mode 100644 index 0000000..142708c --- /dev/null +++ b/tests/test_ws_member_api.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock, patch + +pytest.importorskip("fastapi") +pytest.importorskip("dotenv") + + +def test_list_members_requires_auth(): + """GET /api/projects/{id}/members requires authentication.""" + from fastapi.testclient import TestClient + from src.runtimes.ws_server import app + + with patch("src.runtimes.ws_server._require_hasura"): + client = TestClient(app) + resp = client.get("/api/projects/test-id/members") + assert resp.status_code == 401 + + +def test_add_member_requires_auth(): + """POST /api/projects/{id}/members requires authentication.""" + from fastapi.testclient import TestClient + from src.runtimes.ws_server import app + + with patch("src.runtimes.ws_server._require_hasura"): + client = TestClient(app) + resp = client.post("/api/projects/test-id/members", json={"email": "test@example.com"}) + assert resp.status_code == 401 + + +def test_remove_member_requires_auth(): + """DELETE /api/projects/{id}/members/{sub} requires authentication.""" + from fastapi.testclient import TestClient + from src.runtimes.ws_server import app + + with patch("src.runtimes.ws_server._require_hasura"): + client = TestClient(app) + resp = client.delete("/api/projects/test-id/members/some-sub") + assert resp.status_code == 401 From 93832417ac03d06ac5e1efee5fc217d1b3ac99e7 Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Thu, 12 Feb 2026 17:29:19 +0100 Subject: [PATCH 08/16] feat(ws): add session locking on INIT with force-claim support - Add SESSION_CLAIMED message type to backend and frontend - Acquire project lock during WebSocket INIT - If locked by another user, return project_locked error (unless force=True) - When force-claiming, notify previous session with SESSION_CLAIMED message - Release lock on WebSocket disconnect - Track ws -> (session_id, user_sub) in module-level map for cleanup Co-Authored-By: Claude Opus 4.5 --- frontend/src/types/messages.ts | 1 + src/agent_core.py | 1 + src/runtimes/ws_server.py | 89 ++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/frontend/src/types/messages.ts b/frontend/src/types/messages.ts index ed2cf92..9d13b52 100644 --- a/frontend/src/types/messages.ts +++ b/frontend/src/types/messages.ts @@ -13,6 +13,7 @@ export enum MessageType { HITL_REQUEST = "hitl_request", HITL_RESPONSE = "hitl_response", RUNTIME_ERROR = "runtime_error", + SESSION_CLAIMED = "session_claimed", } export enum Sender { diff --git a/src/agent_core.py b/src/agent_core.py index 1c6c103..7abaab4 100644 --- a/src/agent_core.py +++ b/src/agent_core.py @@ -244,6 +244,7 @@ class MessageType(Enum): HITL_REQUEST = "hitl_request" HITL_RESPONSE = "hitl_response" RUNTIME_ERROR = "runtime_error" + SESSION_CLAIMED = "session_claimed" ERROR = "error" PING = "ping" diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py index 2c4a6bb..5a25296 100644 --- a/src/runtimes/ws_server.py +++ b/src/runtimes/ws_server.py @@ -46,6 +46,10 @@ # Best-effort in-memory limiter for runtime error auto-heal. _runtime_autoheal_state_by_project: dict[str, Any] = {} +# Track active WebSocket connections for session locking. +# Maps WebSocket -> (project_id, user_sub) for lock release on disconnect. +_ws_session_map: dict[WebSocket, tuple[str, str]] = {} + _naming_llm: Any = None @@ -2344,6 +2348,23 @@ async def _handle_ws(ws: WebSocket) -> None: try: raw = await ws.receive_text() except WebSocketDisconnect: + # Release session lock on disconnect + conn_info = _ws_session_map.pop(ws, None) + if conn_info: + sess_id, u_sub = conn_info + + def _release_lock_on_disconnect() -> None: + try: + from src.db.provisioning import hasura_client_from_env + from src.projects.store import release_project_lock + + client = hasura_client_from_env() + release_project_lock(client, project_id=sess_id, user_sub=u_sub) + except Exception: + pass + + # Run lock release in background to avoid blocking + asyncio.create_task(asyncio.to_thread(_release_lock_on_disconnect)) return msg: dict[str, Any] @@ -2424,6 +2445,74 @@ def _load_sync( ) await ws.close(code=1011) return + + # Session locking: check/acquire lock + force_claim = bool(data.get("force", False)) + from src.projects.store import ( + acquire_project_lock, + get_project_lock, + ) + + def _check_and_acquire_lock() -> tuple[str, dict[str, Any] | None]: + client = hasura_client_from_env() + current_lock = get_project_lock(client, project_id=str(session_id)) + if current_lock and current_lock.locked_by_sub != sub and not force_claim: + return "locked", { + "locked_by_email": current_lock.locked_by_email, + "locked_at": current_lock.locked_at, + } + lock = acquire_project_lock( + client, + project_id=str(session_id), + user_sub=sub, + user_email=email, + force=force_claim, + ) + if not lock: + # Race: someone else acquired it between check and acquire + current = get_project_lock(client, project_id=str(session_id)) + return "locked", { + "locked_by_email": current.locked_by_email if current else "unknown", + "locked_at": current.locked_at if current else "", + } + return "ok", None + + # If force-claiming, notify the previous session holder first + if force_claim: + for other_ws, (other_sess, other_sub) in list(_ws_session_map.items()): + if other_sess == str(session_id) and other_sub != sub: + try: + await other_ws.send_json( + Message.new( + MessageType.SESSION_CLAIMED, + {"claimed_by": email}, + session_id=str(session_id), + ).to_dict() + ) + await other_ws.close(code=1000) + except Exception: + pass + _ws_session_map.pop(other_ws, None) + + lock_status, lock_info = await asyncio.to_thread(_check_and_acquire_lock) + if lock_status == "locked": + await ws.send_json( + Message.new( + MessageType.ERROR, + { + "error": "project_locked", + "locked_by": lock_info["locked_by_email"] if lock_info else "unknown", + "locked_at": lock_info["locked_at"] if lock_info else "", + }, + session_id=session_id, + ).to_dict() + ) + await ws.close(code=1008) + return + + # Register this websocket for lock tracking + _ws_session_map[ws] = (str(session_id), sub) + template_id = ( getattr(project, "template_id", None) if project is not None else None ) From b373de93e884bb219a3c9c2d0d70fd298a8e6a53 Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Thu, 12 Feb 2026 17:30:40 +0100 Subject: [PATCH 09/16] feat(store): allow any member to delete/rename projects - Update mark_project_deleted to verify membership instead of ownership - Update hard_delete_project_row to verify membership instead of ownership - Update rename_project to remove owner_sub check (membership already verified) - Add tests for member-based delete permissions Co-Authored-By: Claude Opus 4.5 --- src/projects/store.py | 21 ++++++++++---- tests/test_projects_store.py | 55 ++++++++++++++++++++++++++++++------ 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/projects/store.py b/src/projects/store.py index 2912a8d..501f73a 100644 --- a/src/projects/store.py +++ b/src/projects/store.py @@ -644,7 +644,7 @@ def rename_project( ensure_projects_schema(client) base = slugify(new_name) - # Ensure project exists and is owned. + # Ensure project exists and user is a member (membership checked by get_project_by_id) existing = get_project_by_id(client, owner=owner, project_id=project_id) if not existing: raise PermissionError("project not found") @@ -656,11 +656,12 @@ def rename_project( slug = _candidate_slug(base, suffix=suffix) if slug != existing.slug and not _slug_available(client, slug=slug): continue + # Update without owner_sub check since membership is already verified client.run_sql( f""" UPDATE amicable_meta.projects SET name = {_sql_str(new_name)}, slug = {_sql_str(slug)}, updated_at = now() - WHERE project_id = {_sql_str(project_id)} AND owner_sub = {_sql_str(owner.sub)} AND deleted_at IS NULL; + WHERE project_id = {_sql_str(project_id)} AND deleted_at IS NULL; """.strip() ) updated = get_project_by_id(client, owner=owner, project_id=project_id) @@ -674,12 +675,17 @@ def mark_project_deleted( client: HasuraClient, *, owner: ProjectOwner, project_id: str ) -> None: ensure_projects_schema(client) - # Mark deleted first so it disappears from lists immediately. + # Verify user is a member before allowing delete + if not is_project_member( + client, project_id=project_id, user_sub=owner.sub, user_email=owner.email + ): + raise PermissionError("not a member") + # Mark deleted so it disappears from lists immediately. client.run_sql( f""" UPDATE amicable_meta.projects SET deleted_at = now(), updated_at = now() - WHERE project_id = {_sql_str(project_id)} AND owner_sub = {_sql_str(owner.sub)} AND deleted_at IS NULL; + WHERE project_id = {_sql_str(project_id)} AND deleted_at IS NULL; """.strip() ) @@ -688,10 +694,15 @@ def hard_delete_project_row( client: HasuraClient, *, owner: ProjectOwner, project_id: str ) -> None: ensure_projects_schema(client) + # Verify user is a member before allowing hard delete + if not is_project_member( + client, project_id=project_id, user_sub=owner.sub, user_email=owner.email + ): + raise PermissionError("not a member") client.run_sql( f""" DELETE FROM amicable_meta.projects - WHERE project_id = {_sql_str(project_id)} AND owner_sub = {_sql_str(owner.sub)}; + WHERE project_id = {_sql_str(project_id)}; """.strip() ) diff --git a/tests/test_projects_store.py b/tests/test_projects_store.py index 5148c63..3bbf1b4 100644 --- a/tests/test_projects_store.py +++ b/tests/test_projects_store.py @@ -308,16 +308,15 @@ def run_sql(self, sql: str, *, read_only: bool = False): } return {"result_type": "CommandOk", "result": []} - # UPDATE rename. + # UPDATE rename (membership is checked before SQL call, so no owner_sub in WHERE). if sql_l.startswith("update amicable_meta.projects") and "set name" in sql_l: pid = re.search(r"where project_id\s*=\s*'([^']+)'", sql, flags=re.I).group( 1 ) # type: ignore[union-attr] - sub = re.search(r"and owner_sub\s*=\s*'([^']+)'", sql, flags=re.I).group(1) # type: ignore[union-attr] name = re.search(r"set name\s*=\s*'([^']*)'", sql, flags=re.I).group(1) # type: ignore[union-attr] slug = re.search(r"slug\s*=\s*'([^']*)'", sql, flags=re.I).group(1) # type: ignore[union-attr] row = self.projects.get(pid) - if row and row["owner_sub"] == sub and not row.get("deleted_at"): + if row and not row.get("deleted_at"): # enforce slug uniqueness if any( ( @@ -333,7 +332,7 @@ def run_sql(self, sql: str, *, read_only: bool = False): row["updated_at"] = "t1" return {"result_type": "CommandOk", "result": []} - # UPDATE mark deleted. + # UPDATE mark deleted (membership is checked before SQL call, so no owner_sub in WHERE). if ( sql_l.startswith("update amicable_meta.projects") and "set deleted_at" in sql_l @@ -341,21 +340,19 @@ def run_sql(self, sql: str, *, read_only: bool = False): pid = re.search(r"where project_id\s*=\s*'([^']+)'", sql, flags=re.I).group( 1 ) # type: ignore[union-attr] - sub = re.search(r"and owner_sub\s*=\s*'([^']+)'", sql, flags=re.I).group(1) # type: ignore[union-attr] row = self.projects.get(pid) - if row and row["owner_sub"] == sub and not row.get("deleted_at"): + if row and not row.get("deleted_at"): row["deleted_at"] = "t_del" row["updated_at"] = "t_del" return {"result_type": "CommandOk", "result": []} - # DELETE project. + # DELETE project (membership is checked before SQL call, so no owner_sub in WHERE). if sql_l.startswith("delete from amicable_meta.projects"): pid = re.search(r"where project_id\s*=\s*'([^']+)'", sql, flags=re.I).group( 1 ) # type: ignore[union-attr] - sub = re.search(r"and owner_sub\s*=\s*'([^']+)'", sql, flags=re.I).group(1) # type: ignore[union-attr] row = self.projects.get(pid) - if row and row["owner_sub"] == sub: + if row: self.projects.pop(pid, None) return {"result_type": "CommandOk", "result": []} @@ -677,3 +674,43 @@ def test_project_lock_force_claim() -> None: current = get_project_lock(c, project_id=p.project_id) assert current is not None assert current.locked_by_sub == "u2" + + +def test_member_can_delete_project() -> None: + """Any member can delete a project.""" + c = FakeHasuraClient() + owner = ProjectOwner(sub="u1", email="u1@example.com") + other = ProjectOwner(sub="u2", email="u2@example.com") + + from src.projects.store import add_project_member + + p = create_project(c, owner=owner, name="Deletable") + add_project_member( + c, + project_id=p.project_id, + user_sub="u2", + user_email="u2@example.com", + added_by_sub="u1", + ) + + # u2 (not creator) deletes + mark_project_deleted(c, owner=other, project_id=p.project_id) + + # Verify deleted + assert get_project_by_id(c, owner=owner, project_id=p.project_id) is None + + +def test_non_member_cannot_delete_project() -> None: + """Non-members cannot delete a project.""" + c = FakeHasuraClient() + owner = ProjectOwner(sub="u1", email="u1@example.com") + non_member = ProjectOwner(sub="u3", email="u3@example.com") + + p = create_project(c, owner=owner, name="Protected") + + # u3 (not a member) tries to delete + with pytest.raises(PermissionError): + mark_project_deleted(c, owner=non_member, project_id=p.project_id) + + # Verify not deleted + assert get_project_by_id(c, owner=owner, project_id=p.project_id) is not None From 2b4e81907ea4b13775354dbcd6591757f8b039bb Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Thu, 12 Feb 2026 17:52:01 +0100 Subject: [PATCH 10/16] feat(frontend): add project sharing UI and session locking modals - Create ProjectMembers.tsx component for managing project members - Lists current members with email and avatar - Add member by email with validation - Remove member button (disabled when only one member remains) - Shows "Pending" badge for invited users who haven't logged in yet - Create ProjectLockedModal.tsx for locked project handling - Shows which user is editing the project - "Go back" button returns to project list - "Take over session" button with confirmation warning - Create SessionClaimedModal.tsx for session takeover notification - Shows message when another user takes over the session - Redirects to project list on dismiss - Integrate modals into Create screen - Handle ERROR with project_locked code to show ProjectLockedModal - Handle SESSION_CLAIMED message to show SessionClaimedModal - Add handlers for take over and go back actions --- .../src/components/ProjectLockedModal.tsx | 124 +++++++++++ frontend/src/components/ProjectMembers.tsx | 206 ++++++++++++++++++ .../src/components/SessionClaimedModal.tsx | 61 ++++++ frontend/src/screens/Create/index.tsx | 59 +++++ 4 files changed, 450 insertions(+) create mode 100644 frontend/src/components/ProjectLockedModal.tsx create mode 100644 frontend/src/components/ProjectMembers.tsx create mode 100644 frontend/src/components/SessionClaimedModal.tsx diff --git a/frontend/src/components/ProjectLockedModal.tsx b/frontend/src/components/ProjectLockedModal.tsx new file mode 100644 index 0000000..41fe99c --- /dev/null +++ b/frontend/src/components/ProjectLockedModal.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import { AlertTriangle, ArrowLeft, UserCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface ProjectLockedModalProps { + lockedByEmail: string; + lockedAt?: string; + onTakeOver: () => void; + onGoBack: () => void; + className?: string; +} + +export function ProjectLockedModal({ + lockedByEmail, + lockedAt, + onTakeOver, + onGoBack, + className, +}: ProjectLockedModalProps) { + const [confirmTakeover, setConfirmTakeover] = useState(false); + + const formatLockedTime = (isoString?: string) => { + if (!isoString) return null; + try { + const date = new Date(isoString); + return date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + } catch { + return null; + } + }; + + const lockedTime = formatLockedTime(lockedAt); + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Project in use +

+

+ Someone else is currently editing +

+
+
+
+ + {/* Content */} +
+

+ This project is being edited by{" "} + {lockedByEmail} + {lockedTime && ( + since {lockedTime} + )} + . +

+ + {!confirmTakeover ? ( +
+ + +
+ ) : ( +
+
+ +

+ Taking over will disconnect{" "} + {lockedByEmail} from the + project. They may lose unsaved changes. +

+
+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/ProjectMembers.tsx b/frontend/src/components/ProjectMembers.tsx new file mode 100644 index 0000000..a37c8c3 --- /dev/null +++ b/frontend/src/components/ProjectMembers.tsx @@ -0,0 +1,206 @@ +import { useState, useCallback, useEffect } from "react"; +import { Loader2, UserPlus, X, Users } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { AGENT_CONFIG } from "../config/agent"; +import { cn } from "@/lib/utils"; + +interface ProjectMember { + user_sub: string | null; + user_email: string; + added_at: string | null; +} + +interface ProjectMembersProps { + projectId: string; + className?: string; +} + +export function ProjectMembers({ projectId, className }: ProjectMembersProps) { + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newEmail, setNewEmail] = useState(""); + const [adding, setAdding] = useState(false); + const [removingEmail, setRemovingEmail] = useState(null); + + const fetchMembers = useCallback(async () => { + try { + setLoading(true); + setError(null); + const res = await fetch( + `${AGENT_CONFIG.HTTP_URL}api/projects/${projectId}/members`, + { credentials: "include" } + ); + if (!res.ok) { + throw new Error("Failed to load members"); + } + const data = await res.json(); + setMembers(data.members || []); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load members"); + } finally { + setLoading(false); + } + }, [projectId]); + + useEffect(() => { + fetchMembers(); + }, [fetchMembers]); + + const handleAddMember = async (e: React.FormEvent) => { + e.preventDefault(); + const email = newEmail.trim().toLowerCase(); + if (!email || !email.includes("@")) return; + + try { + setAdding(true); + setError(null); + const res = await fetch( + `${AGENT_CONFIG.HTTP_URL}api/projects/${projectId}/members`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + } + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Failed to add member"); + } + setNewEmail(""); + await fetchMembers(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to add member"); + } finally { + setAdding(false); + } + }; + + const handleRemoveMember = async (userSub: string, userEmail: string) => { + if (members.length <= 1) return; + + try { + setRemovingEmail(userEmail); + setError(null); + const res = await fetch( + `${AGENT_CONFIG.HTTP_URL}api/projects/${projectId}/members/${encodeURIComponent(userSub)}`, + { + method: "DELETE", + credentials: "include", + } + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Failed to remove member"); + } + await fetchMembers(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to remove member"); + } finally { + setRemovingEmail(null); + } + }; + + const canRemove = members.length > 1; + + return ( +
+
+ + People with access +
+ + {loading ? ( +
+ +
+ ) : ( + <> + {/* Member list */} +
+ {members.map((member) => ( +
+
+
+ {member.user_email.charAt(0).toUpperCase()} +
+ + {member.user_email} + + {!member.user_sub && ( + + Pending + + )} +
+ +
+ ))} +
+ + {/* Add member form */} +
+ setNewEmail(e.target.value)} + disabled={adding} + className="flex-1" + /> + +
+ + {/* Error message */} + {error && ( +

+ {error} +

+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/SessionClaimedModal.tsx b/frontend/src/components/SessionClaimedModal.tsx new file mode 100644 index 0000000..3b0d848 --- /dev/null +++ b/frontend/src/components/SessionClaimedModal.tsx @@ -0,0 +1,61 @@ +import { UserX } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface SessionClaimedModalProps { + claimedByEmail?: string; + onDismiss: () => void; + className?: string; +} + +export function SessionClaimedModal({ + claimedByEmail, + onDismiss, + className, +}: SessionClaimedModalProps) { + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Session ended +

+

+ Another user took over this project +

+
+
+
+ + {/* Content */} +
+

+ Your editing session was taken over + {claimedByEmail && ( + <> + {" "} + by {claimedByEmail} + + )} + . You'll be redirected to the project list. +

+ + +
+
+
+ ); +} diff --git a/frontend/src/screens/Create/index.tsx b/frontend/src/screens/Create/index.tsx index 83bf40d..ca2cb02 100644 --- a/frontend/src/screens/Create/index.tsx +++ b/frontend/src/screens/Create/index.tsx @@ -20,6 +20,8 @@ import { type JsonObject, type RuntimeErrorPayload, } from "../../types/messages"; +import { ProjectLockedModal } from "@/components/ProjectLockedModal"; +import { SessionClaimedModal } from "@/components/SessionClaimedModal"; import { useCallback, useEffect, @@ -220,6 +222,13 @@ const Create = () => { const [pendingImages, setPendingImages] = useState([]); const [attachmentError, setAttachmentError] = useState(null); const fileInputRef = useRef(null); + const [projectLockedInfo, setProjectLockedInfo] = useState<{ + email: string; + lockedAt?: string; + } | null>(null); + const [sessionClaimed, setSessionClaimed] = useState<{ + byEmail?: string; + } | null>(null); type ToolRun = { runId: string; @@ -525,6 +534,16 @@ const Create = () => { }, [MessageType.ERROR]: (message: Message) => { + // Check for project_locked error + const md = asObj(message.data); + if (md && md.code === "project_locked") { + const lockedBy = md.locked_by as { email?: string; at?: string } | undefined; + setProjectLockedInfo({ + email: lockedBy?.email || "another user", + lockedAt: lockedBy?.at, + }); + return; + } setMessages((prev) => [ ...prev, { @@ -538,6 +557,13 @@ const Create = () => { ]); }, + [MessageType.SESSION_CLAIMED]: (message: Message) => { + const md = asObj(message.data); + setSessionClaimed({ + byEmail: md?.claimed_by_email as string | undefined, + }); + }, + [MessageType.AGENT_PARTIAL]: (message: Message) => { const text = message.data.text; const id = message.id; @@ -1134,6 +1160,22 @@ const Create = () => { }; }, [isChatResizing, chatWidth]); + const handleTakeOverSession = useCallback(() => { + // Reconnect with force=true to take over the session + setProjectLockedInfo(null); + // Send INIT with force_claim to take over the session + send(MessageType.INIT, { force_claim: true }); + }, [send]); + + const handleGoBackToProjects = useCallback(() => { + navigate("/"); + }, [navigate]); + + const handleSessionClaimedDismiss = useCallback(() => { + setSessionClaimed(null); + navigate("/"); + }, [navigate]); + const handleSendMessage = () => { const text = inputValue.trim(); const imgs = pendingImages; @@ -2548,6 +2590,23 @@ const Create = () => { + + {/* Project locking modals */} + {projectLockedInfo && ( + + )} + + {sessionClaimed && ( + + )} ); }; From afb4ae849ec3e4384356603691edea55ccd03bab Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Thu, 12 Feb 2026 17:59:44 +0100 Subject: [PATCH 11/16] fix: resolve frontend-backend protocol mismatches for session locking Critical fixes: - ERROR message: use `code` instead of `error`, send `locked_by` as object - Force-claim: accept both `force_claim` and `force` parameters - SESSION_CLAIMED: use `claimed_by_email` field name Additional fixes: - set_project_slug/set_gitlab_metadata: check membership instead of ownership - list_projects: use EXISTS instead of JOIN to avoid potential duplicates - Add remove_project_member_by_email for pending invitations - Update ProjectMembers.tsx to allow removing pending members Update FakeHasuraClient for new EXISTS query pattern. --- frontend/src/components/ProjectMembers.tsx | 26 ++++++------ src/projects/store.py | 46 ++++++++++++++++++---- src/runtimes/ws_server.py | 46 +++++++++++++++++++--- tests/test_projects_store.py | 4 +- 4 files changed, 93 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/ProjectMembers.tsx b/frontend/src/components/ProjectMembers.tsx index a37c8c3..23973fc 100644 --- a/frontend/src/components/ProjectMembers.tsx +++ b/frontend/src/components/ProjectMembers.tsx @@ -78,19 +78,20 @@ export function ProjectMembers({ projectId, className }: ProjectMembersProps) { } }; - const handleRemoveMember = async (userSub: string, userEmail: string) => { + const handleRemoveMember = async (userSub: string | null, userEmail: string) => { if (members.length <= 1) return; try { setRemovingEmail(userEmail); setError(null); - const res = await fetch( - `${AGENT_CONFIG.HTTP_URL}api/projects/${projectId}/members/${encodeURIComponent(userSub)}`, - { - method: "DELETE", - credentials: "include", - } - ); + // Use by-email endpoint for pending members (null user_sub), otherwise by user_sub + const endpoint = userSub + ? `${AGENT_CONFIG.HTTP_URL}api/projects/${projectId}/members/${encodeURIComponent(userSub)}` + : `${AGENT_CONFIG.HTTP_URL}api/projects/${projectId}/members/by-email/${encodeURIComponent(userEmail)}`; + const res = await fetch(endpoint, { + method: "DELETE", + credentials: "include", + }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "Failed to remove member"); @@ -141,22 +142,19 @@ export function ProjectMembers({ projectId, className }: ProjectMembersProps) { + )} + {showMembers && projectInfo && ( + + )} +
; } export class WebSocketBus { @@ -83,11 +84,14 @@ export class WebSocketBus { this.reconnectAttempts = 0; this.config.messageBus.setConnected(true); - const initData = this.config.sessionId - ? { session_id: this.config.sessionId } - : {}; + const initData = { + ...(this.config.sessionId ? { session_id: this.config.sessionId } : {}), + ...this.config.initExtra, + }; console.log("Sending INIT message with session_id:", this.config.sessionId); this.sendMessage(createMessage(MessageType.INIT, initData)); + // Clear initExtra after first use so reconnects don't replay it + this.config.initExtra = undefined; if (!settled) { settled = true; @@ -110,6 +114,7 @@ export class WebSocketBus { if ( event.code !== 1000 && + event.code !== 1008 && // Don't auto-reconnect on policy violations (e.g. project_locked) this.reconnectAttempts < this.maxReconnectAttempts ) { this.scheduleReconnect(); @@ -238,7 +243,8 @@ export class WebSocketBus { export const createWebSocketBus = ( url: string, messageBus: MessageBus, - sessionId?: string + sessionId?: string, + initExtra?: Record ): WebSocketBus => { - return new WebSocketBus({ url, messageBus, sessionId }); + return new WebSocketBus({ url, messageBus, sessionId, initExtra }); }; diff --git a/src/projects/store.py b/src/projects/store.py index ccb3413..895959f 100644 --- a/src/projects/store.py +++ b/src/projects/store.py @@ -5,6 +5,7 @@ import threading import uuid from dataclasses import dataclass +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover @@ -63,6 +64,7 @@ def ensure_projects_schema(client: HasuraClient) -> None: gitlab_path text NULL, gitlab_web_url text NULL, locked_by_sub text NULL, + locked_by_email text NULL, locked_at timestamptz NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), @@ -80,6 +82,8 @@ def ensure_projects_schema(client: HasuraClient) -> None: ADD COLUMN IF NOT EXISTS gitlab_web_url text NULL; ALTER TABLE amicable_meta.projects ADD COLUMN IF NOT EXISTS locked_by_sub text NULL; + ALTER TABLE amicable_meta.projects + ADD COLUMN IF NOT EXISTS locked_by_email text NULL; ALTER TABLE amicable_meta.projects ADD COLUMN IF NOT EXISTS locked_at timestamptz NULL; @@ -475,7 +479,7 @@ def set_project_slug( AND EXISTS ( SELECT 1 FROM amicable_meta.project_members pm WHERE pm.project_id = p.project_id - AND (pm.user_sub = {_sql_str(owner.sub)} OR pm.user_email = {_sql_str(owner.email)}) + AND (pm.user_sub = {_sql_str(owner.sub)} OR pm.user_email = {_sql_str(owner.email.lower())}) ); """.strip() ) @@ -507,7 +511,7 @@ def set_gitlab_metadata( AND EXISTS ( SELECT 1 FROM amicable_meta.project_members pm WHERE pm.project_id = p.project_id - AND (pm.user_sub = {_sql_str(owner.sub)} OR pm.user_email = {_sql_str(owner.email)}) + AND (pm.user_sub = {_sql_str(owner.sub)} OR pm.user_email = {_sql_str(owner.email.lower())}) ); """.strip() ) @@ -784,6 +788,7 @@ def add_project_member( user_sub=user_sub, user_email=user_email, added_by_sub=added_by_sub, + added_at=datetime.now(tz=timezone.utc).isoformat(), ) @@ -875,7 +880,7 @@ def get_project_lock(client: HasuraClient, *, project_id: str) -> ProjectLock | ensure_projects_schema(client) res = client.run_sql( f""" - SELECT locked_by_sub, locked_at + SELECT locked_by_sub, locked_by_email, locked_at FROM amicable_meta.projects WHERE project_id = {_sql_str(project_id)} AND deleted_at IS NULL AND locked_by_sub IS NOT NULL LIMIT 1; @@ -886,21 +891,10 @@ def get_project_lock(client: HasuraClient, *, project_id: str) -> ProjectLock | if not rows or not rows[0].get("locked_by_sub"): return None r = rows[0] - # Get email from members table - email_res = client.run_sql( - f""" - SELECT user_email FROM amicable_meta.project_members - WHERE project_id = {_sql_str(project_id)} AND user_sub = {_sql_str(str(r["locked_by_sub"]))} - LIMIT 1; - """.strip(), - read_only=True, - ) - email_rows = _tuples_to_dicts(email_res) - email = str(email_rows[0]["user_email"]) if email_rows else "" return ProjectLock( project_id=project_id, locked_by_sub=str(r["locked_by_sub"]), - locked_by_email=email, + locked_by_email=str(r.get("locked_by_email") or ""), locked_at=str(r.get("locked_at") or ""), ) @@ -922,7 +916,8 @@ def acquire_project_lock( client.run_sql( f""" UPDATE amicable_meta.projects - SET locked_by_sub = {_sql_str(user_sub)}, locked_at = now(), updated_at = now() + SET locked_by_sub = {_sql_str(user_sub)}, locked_by_email = {_sql_str(user_email)}, + locked_at = now(), updated_at = now() WHERE project_id = {_sql_str(project_id)} AND deleted_at IS NULL; """.strip() ) @@ -931,7 +926,8 @@ def acquire_project_lock( client.run_sql( f""" UPDATE amicable_meta.projects - SET locked_by_sub = {_sql_str(user_sub)}, locked_at = now(), updated_at = now() + SET locked_by_sub = {_sql_str(user_sub)}, locked_by_email = {_sql_str(user_email)}, + locked_at = now(), updated_at = now() WHERE project_id = {_sql_str(project_id)} AND deleted_at IS NULL AND (locked_by_sub IS NULL OR locked_by_sub = {_sql_str(user_sub)}); @@ -958,7 +954,7 @@ def release_project_lock( client.run_sql( f""" UPDATE amicable_meta.projects - SET locked_by_sub = NULL, locked_at = NULL, updated_at = now() + SET locked_by_sub = NULL, locked_by_email = NULL, locked_at = NULL, updated_at = now() WHERE project_id = {_sql_str(project_id)} AND locked_by_sub = {_sql_str(user_sub)} AND deleted_at IS NULL; """.strip() ) diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py index 35b32fe..a4d68ce 100644 --- a/src/runtimes/ws_server.py +++ b/src/runtimes/ws_server.py @@ -2392,8 +2392,8 @@ def _release_lock_on_disconnect() -> None: client = hasura_client_from_env() release_project_lock(client, project_id=sess_id, user_sub=u_sub) - except Exception: - pass + except Exception as exc: + logger.warning("Failed to release lock for session %s: %s", sess_id, exc) # Run lock release in background to avoid blocking asyncio.create_task(asyncio.to_thread(_release_lock_on_disconnect)) @@ -2526,7 +2526,19 @@ def _check_and_acquire_lock() -> tuple[str, dict[str, Any] | None]: pass _ws_session_map.pop(other_ws, None) - lock_status, lock_info = await asyncio.to_thread(_check_and_acquire_lock) + try: + lock_status, lock_info = await asyncio.to_thread(_check_and_acquire_lock) + except Exception as exc: + logger.exception("Lock acquisition failed for session %s: %s", session_id, exc) + await ws.send_json( + Message.new( + MessageType.ERROR, + {"code": "lock_error", "error": "Failed to check session lock"}, + session_id=session_id, + ).to_dict() + ) + await ws.close(code=1011) + return if lock_status == "locked": await ws.send_json( Message.new( diff --git a/tests/test_projects_store.py b/tests/test_projects_store.py index 98ce8c8..1f18f18 100644 --- a/tests/test_projects_store.py +++ b/tests/test_projects_store.py @@ -61,13 +61,13 @@ def run_sql(self, sql: str, *, read_only: bool = False): return { "result_type": "TuplesOk", "result": [ - ["locked_by_sub", "locked_at"], - [row["locked_by_sub"], row.get("locked_at")], + ["locked_by_sub", "locked_by_email", "locked_at"], + [row["locked_by_sub"], row.get("locked_by_email", ""), row.get("locked_at")], ], } return { "result_type": "TuplesOk", - "result": [["locked_by_sub", "locked_at"]], + "result": [["locked_by_sub", "locked_by_email", "locked_at"]], } # UPDATE lock (acquire/release) - check for "set locked_by_sub" @@ -86,14 +86,19 @@ def run_sql(self, sql: str, *, read_only: bool = False): ) if sub_match and row.get("locked_by_sub") == sub_match.group(1): row["locked_by_sub"] = None + row["locked_by_email"] = None row["locked_at"] = None else: - # Acquire - extract the new user_sub + # Acquire - extract the new user_sub and email sub_match = re.search( r"set locked_by_sub\s*=\s*'([^']+)'", sql, flags=re.I ) + email_match = re.search( + r"locked_by_email\s*=\s*'([^']+)'", sql, flags=re.I + ) if sub_match: new_sub = sub_match.group(1) + new_email = email_match.group(1) if email_match else "" # Check for atomic conditional update pattern: # (locked_by_sub IS NULL OR locked_by_sub = 'user') if "locked_by_sub is null or locked_by_sub" in sql_l: @@ -101,11 +106,13 @@ def run_sql(self, sql: str, *, read_only: bool = False): current_holder = row.get("locked_by_sub") if current_holder is None or current_holder == new_sub: row["locked_by_sub"] = new_sub + row["locked_by_email"] = new_email row["locked_at"] = "now" # else: don't update (someone else holds it) else: # Unconditional (force) acquire row["locked_by_sub"] = new_sub + row["locked_by_email"] = new_email row["locked_at"] = "now" return {"result_type": "CommandOk", "result": []} @@ -455,9 +462,19 @@ def run_sql(self, sql: str, *, read_only: bool = False): if sql_l.startswith("delete from amicable_meta.project_members"): pid_match = re.search(r"where project_id\s*=\s*'([^']+)'", sql, flags=re.I) sub_match = re.search(r"and user_sub\s*=\s*'([^']+)'", sql, flags=re.I) + email_match = re.search(r"and user_email\s*=\s*'([^']+)'", sql, flags=re.I) if pid_match and sub_match: key = (pid_match.group(1), sub_match.group(1)) self.members.pop(key, None) + elif pid_match and email_match: + target_pid = pid_match.group(1) + target_email = email_match.group(1).lower() + to_remove = [ + k for k, v in self.members.items() + if v["project_id"] == target_pid and v["user_email"] == target_email + ] + for k in to_remove: + self.members.pop(k, None) return {"result_type": "CommandOk", "result": []} raise AssertionError(f"Unhandled SQL in test fake: {sql}") @@ -588,7 +605,6 @@ def test_project_locking() -> None: """Test session locking prevents concurrent access.""" c = FakeHasuraClient() owner1 = ProjectOwner(sub="u1", email="u1@example.com") - owner2 = ProjectOwner(sub="u2", email="u2@example.com") p = create_project(c, owner=owner1, name="Lockable") diff --git a/tests/test_ws_member_api.py b/tests/test_ws_member_api.py index 142708c..49a9ce5 100644 --- a/tests/test_ws_member_api.py +++ b/tests/test_ws_member_api.py @@ -1,18 +1,26 @@ from __future__ import annotations import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import patch pytest.importorskip("fastapi") pytest.importorskip("dotenv") +def _patch_auth_rejected(): + """Patch _get_owner_from_request to always reject, regardless of AUTH_MODE.""" + return patch( + "src.runtimes.ws_server._get_owner_from_request", + side_effect=PermissionError("not authenticated"), + ) + + def test_list_members_requires_auth(): """GET /api/projects/{id}/members requires authentication.""" from fastapi.testclient import TestClient from src.runtimes.ws_server import app - with patch("src.runtimes.ws_server._require_hasura"): + with patch("src.runtimes.ws_server._require_hasura"), _patch_auth_rejected(): client = TestClient(app) resp = client.get("/api/projects/test-id/members") assert resp.status_code == 401 @@ -23,7 +31,7 @@ def test_add_member_requires_auth(): from fastapi.testclient import TestClient from src.runtimes.ws_server import app - with patch("src.runtimes.ws_server._require_hasura"): + with patch("src.runtimes.ws_server._require_hasura"), _patch_auth_rejected(): client = TestClient(app) resp = client.post("/api/projects/test-id/members", json={"email": "test@example.com"}) assert resp.status_code == 401 @@ -34,7 +42,7 @@ def test_remove_member_requires_auth(): from fastapi.testclient import TestClient from src.runtimes.ws_server import app - with patch("src.runtimes.ws_server._require_hasura"): + with patch("src.runtimes.ws_server._require_hasura"), _patch_auth_rejected(): client = TestClient(app) resp = client.delete("/api/projects/test-id/members/some-sub") assert resp.status_code == 401 From 9fb8a7be8eb451a943d514c86b87a75916fe292c Mon Sep 17 00:00:00 2001 From: Finn Melzer Date: Fri, 13 Feb 2026 10:08:48 +0100 Subject: [PATCH 13/16] fix: address second round of PR review feedback - Fix React.FormEvent import to use named FormEvent import - Remove overly-restrictive NOT EXISTS in migration, use LOWER() for emails - Allow add_project_member upsert to backfill user_sub for pending invites - Add atomic membership check (EXISTS) in rename_project UPDATE SQL - Validate target member exists before reporting successful removal - Wrap WS handler loop in try/finally for reliable lock release on all exits - Log force-claim notification failures instead of silent pass - Fix B023 lint: bind loop variables in _check_and_acquire_lock closure - Store asyncio.create_task reference to avoid RUF006 lint warning --- frontend/src/components/ProjectMembers.tsx | 4 +- src/projects/store.py | 37 ++++++---- src/runtimes/ws_server.py | 80 ++++++++++++++-------- tests/test_projects_store.py | 8 ++- tests/test_ws_member_api.py | 6 +- 5 files changed, 87 insertions(+), 48 deletions(-) diff --git a/frontend/src/components/ProjectMembers.tsx b/frontend/src/components/ProjectMembers.tsx index 23973fc..0fb3b32 100644 --- a/frontend/src/components/ProjectMembers.tsx +++ b/frontend/src/components/ProjectMembers.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, type FormEvent } from "react"; import { Loader2, UserPlus, X, Users } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -48,7 +48,7 @@ export function ProjectMembers({ projectId, className }: ProjectMembersProps) { fetchMembers(); }, [fetchMembers]); - const handleAddMember = async (e: React.FormEvent) => { + const handleAddMember = async (e: FormEvent) => { e.preventDefault(); const email = newEmail.trim().toLowerCase(); if (!email || !email.includes("@")) return; diff --git a/src/projects/store.py b/src/projects/store.py index 895959f..3cc9f89 100644 --- a/src/projects/store.py +++ b/src/projects/store.py @@ -5,7 +5,7 @@ import threading import uuid from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover @@ -102,13 +102,9 @@ def ensure_projects_schema(client: HasuraClient) -> None: -- Migration: add existing project owners as members INSERT INTO amicable_meta.project_members (project_id, user_sub, user_email, added_at) - SELECT p.project_id, p.owner_sub, p.owner_email, p.created_at + SELECT p.project_id, p.owner_sub, LOWER(p.owner_email), p.created_at FROM amicable_meta.projects p WHERE p.deleted_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM amicable_meta.project_members pm - WHERE pm.project_id = p.project_id - ) ON CONFLICT DO NOTHING; """.strip() ) @@ -675,12 +671,16 @@ def rename_project( slug = _candidate_slug(base, suffix=suffix) if slug != existing.slug and not _slug_available(client, slug=slug): continue - # Update without owner_sub check since membership is already verified client.run_sql( f""" UPDATE amicable_meta.projects SET name = {_sql_str(new_name)}, slug = {_sql_str(slug)}, updated_at = now() - WHERE project_id = {_sql_str(project_id)} AND deleted_at IS NULL; + WHERE project_id = {_sql_str(project_id)} AND deleted_at IS NULL + AND EXISTS ( + SELECT 1 FROM amicable_meta.project_members pm + WHERE pm.project_id = {_sql_str(project_id)} + AND (pm.user_sub = {_sql_str(owner.sub)} OR pm.user_email = {_sql_str(owner.email.lower())}) + ); """.strip() ) updated = get_project_by_id(client, owner=owner, project_id=project_id) @@ -768,9 +768,9 @@ def add_project_member( ensure_projects_schema(client) user_email = user_email.strip().lower() - # Check if already a member by email + # Check if already a member by email — skip only if no new user_sub to backfill existing = _get_member_by_email(client, project_id=project_id, user_email=user_email) - if existing: + if existing and (existing.user_sub or not user_sub): return existing sub_sql = _sql_str(user_sub) if user_sub else "NULL" @@ -783,12 +783,16 @@ def add_project_member( ON CONFLICT (project_id, user_email) DO UPDATE SET user_sub = COALESCE(EXCLUDED.user_sub, amicable_meta.project_members.user_sub); """.strip() ) + # Re-fetch to get DB-populated fields (added_at, resolved user_sub from upsert) + member = _get_member_by_email(client, project_id=project_id, user_email=user_email) + if member: + return member return ProjectMember( project_id=project_id, user_sub=user_sub, user_email=user_email, added_by_sub=added_by_sub, - added_at=datetime.now(tz=timezone.utc).isoformat(), + added_at=datetime.now(tz=UTC).isoformat(), ) @@ -821,11 +825,13 @@ def list_project_members(client: HasuraClient, *, project_id: str) -> list[Proje def remove_project_member( client: HasuraClient, *, project_id: str, user_sub: str ) -> bool: - """Remove a member from a project. Returns False if they were the last member.""" + """Remove a member from a project. Returns False if they were the last member or target not found.""" ensure_projects_schema(client) members = list_project_members(client, project_id=project_id) if len(members) <= 1: return False + if not any(m.user_sub == user_sub for m in members): + return False client.run_sql( f""" DELETE FROM amicable_meta.project_members @@ -838,15 +844,18 @@ def remove_project_member( def remove_project_member_by_email( client: HasuraClient, *, project_id: str, user_email: str ) -> bool: - """Remove a pending member by email. Returns False if they were the last member.""" + """Remove a pending member by email. Returns False if they were the last member or target not found.""" ensure_projects_schema(client) + user_email = user_email.strip().lower() members = list_project_members(client, project_id=project_id) if len(members) <= 1: return False + if not any(m.user_email == user_email for m in members): + return False client.run_sql( f""" DELETE FROM amicable_meta.project_members - WHERE project_id = {_sql_str(project_id)} AND user_email = {_sql_str(user_email.lower())}; + WHERE project_id = {_sql_str(project_id)} AND user_email = {_sql_str(user_email)}; """.strip() ) return True diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py index a4d68ce..140eb67 100644 --- a/src/runtimes/ws_server.py +++ b/src/runtimes/ws_server.py @@ -49,6 +49,7 @@ # Track active WebSocket connections for session locking. # Maps WebSocket -> (project_id, user_sub) for lock release on disconnect. _ws_session_map: dict[WebSocket, tuple[str, str]] = {} +_background_tasks: set[asyncio.Task[Any]] = set() _naming_llm: Any = None @@ -1218,7 +1219,11 @@ async def api_remove_project_member( return JSONResponse({"error": "not_authenticated"}, status_code=401) from src.db.provisioning import hasura_client_from_env - from src.projects.store import ProjectOwner, get_project_by_id, remove_project_member + from src.projects.store import ( + ProjectOwner, + get_project_by_id, + remove_project_member, + ) def _remove_sync(): client = hasura_client_from_env() @@ -1250,7 +1255,11 @@ async def api_remove_project_member_by_email( return JSONResponse({"error": "not_authenticated"}, status_code=401) from src.db.provisioning import hasura_client_from_env - from src.projects.store import ProjectOwner, get_project_by_id, remove_project_member_by_email + from src.projects.store import ( + ProjectOwner, + get_project_by_id, + remove_project_member_by_email, + ) def _remove_sync(): client = hasura_client_from_env() @@ -2376,27 +2385,11 @@ async def _handle_ws(ws: WebSocket) -> None: _agent = Agent() agent = _agent - while True: + try: + while True: try: raw = await ws.receive_text() except WebSocketDisconnect: - # Release session lock on disconnect - conn_info = _ws_session_map.pop(ws, None) - if conn_info: - sess_id, u_sub = conn_info - - def _release_lock_on_disconnect() -> None: - try: - from src.db.provisioning import hasura_client_from_env - from src.projects.store import release_project_lock - - client = hasura_client_from_env() - release_project_lock(client, project_id=sess_id, user_sub=u_sub) - except Exception as exc: - logger.warning("Failed to release lock for session %s: %s", sess_id, exc) - - # Run lock release in background to avoid blocking - asyncio.create_task(asyncio.to_thread(_release_lock_on_disconnect)) return msg: dict[str, Any] @@ -2485,24 +2478,29 @@ def _load_sync( get_project_lock, ) - def _check_and_acquire_lock() -> tuple[str, dict[str, Any] | None]: + def _check_and_acquire_lock( + _sid: str = str(session_id), + _sub: str = sub, + _email: str = email, + _force: bool = force_claim, + ) -> tuple[str, dict[str, Any] | None]: client = hasura_client_from_env() - current_lock = get_project_lock(client, project_id=str(session_id)) - if current_lock and current_lock.locked_by_sub != sub and not force_claim: + current_lock = get_project_lock(client, project_id=_sid) + if current_lock and current_lock.locked_by_sub != _sub and not _force: return "locked", { "locked_by_email": current_lock.locked_by_email, "locked_at": current_lock.locked_at, } lock = acquire_project_lock( client, - project_id=str(session_id), - user_sub=sub, - user_email=email, - force=force_claim, + project_id=_sid, + user_sub=_sub, + user_email=_email, + force=_force, ) if not lock: # Race: someone else acquired it between check and acquire - current = get_project_lock(client, project_id=str(session_id)) + current = get_project_lock(client, project_id=_sid) return "locked", { "locked_by_email": current.locked_by_email if current else "unknown", "locked_at": current.locked_at if current else "", @@ -2523,7 +2521,13 @@ def _check_and_acquire_lock() -> tuple[str, dict[str, Any] | None]: ) await other_ws.close(code=1000) except Exception: - pass + logger.debug( + "Failed to notify/close previous session websocket " + "(session_id=%s, previous_sub=%s)", + session_id, + other_sub, + exc_info=True, + ) _ws_session_map.pop(other_ws, None) try: @@ -2933,6 +2937,24 @@ def _check_and_acquire_lock() -> tuple[str, dict[str, Any] | None]: ).to_dict() ) continue + finally: + conn_info = _ws_session_map.pop(ws, None) + if conn_info: + sess_id, u_sub = conn_info + + def _release_lock_on_disconnect() -> None: + try: + from src.db.provisioning import hasura_client_from_env + from src.projects.store import release_project_lock + + client = hasura_client_from_env() + release_project_lock(client, project_id=sess_id, user_sub=u_sub) + except Exception as exc: + logger.warning("Failed to release lock for session %s: %s", sess_id, exc) + + task = asyncio.create_task(asyncio.to_thread(_release_lock_on_disconnect)) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) @app.websocket("/") diff --git a/tests/test_projects_store.py b/tests/test_projects_store.py index 1f18f18..409df12 100644 --- a/tests/test_projects_store.py +++ b/tests/test_projects_store.py @@ -610,8 +610,8 @@ def test_project_locking() -> None: # Add u2 as member from src.projects.store import ( - add_project_member, acquire_project_lock, + add_project_member, get_project_lock, release_project_lock, ) @@ -660,7 +660,11 @@ def test_project_lock_force_claim() -> None: p = create_project(c, owner=owner1, name="Forceable") - from src.projects.store import add_project_member, acquire_project_lock, get_project_lock + from src.projects.store import ( + acquire_project_lock, + add_project_member, + get_project_lock, + ) add_project_member( c, diff --git a/tests/test_ws_member_api.py b/tests/test_ws_member_api.py index 49a9ce5..3777eb2 100644 --- a/tests/test_ws_member_api.py +++ b/tests/test_ws_member_api.py @@ -1,8 +1,9 @@ from __future__ import annotations -import pytest from unittest.mock import patch +import pytest + pytest.importorskip("fastapi") pytest.importorskip("dotenv") @@ -18,6 +19,7 @@ def _patch_auth_rejected(): def test_list_members_requires_auth(): """GET /api/projects/{id}/members requires authentication.""" from fastapi.testclient import TestClient + from src.runtimes.ws_server import app with patch("src.runtimes.ws_server._require_hasura"), _patch_auth_rejected(): @@ -29,6 +31,7 @@ def test_list_members_requires_auth(): def test_add_member_requires_auth(): """POST /api/projects/{id}/members requires authentication.""" from fastapi.testclient import TestClient + from src.runtimes.ws_server import app with patch("src.runtimes.ws_server._require_hasura"), _patch_auth_rejected(): @@ -40,6 +43,7 @@ def test_add_member_requires_auth(): def test_remove_member_requires_auth(): """DELETE /api/projects/{id}/members/{sub} requires authentication.""" from fastapi.testclient import TestClient + from src.runtimes.ws_server import app with patch("src.runtimes.ws_server._require_hasura"), _patch_auth_rejected(): From c6fef348a4ecf3e7081b0d012bd575fcee55b482 Mon Sep 17 00:00:00 2001 From: Finn Date: Fri, 13 Feb 2026 10:16:09 +0100 Subject: [PATCH 14/16] Update src/runtimes/ws_server.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/runtimes/ws_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py index 140eb67..5c84b3d 100644 --- a/src/runtimes/ws_server.py +++ b/src/runtimes/ws_server.py @@ -2386,7 +2386,7 @@ async def _handle_ws(ws: WebSocket) -> None: agent = _agent try: - while True: + while True: try: raw = await ws.receive_text() except WebSocketDisconnect: From 5c0263e66d601256efd3deff3bfa4362b1db166e Mon Sep 17 00:00:00 2001 From: Finn Date: Fri, 13 Feb 2026 10:17:05 +0100 Subject: [PATCH 15/16] Update src/runtimes/ws_server.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/runtimes/ws_server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py index 5c84b3d..72c27b0 100644 --- a/src/runtimes/ws_server.py +++ b/src/runtimes/ws_server.py @@ -1232,13 +1232,13 @@ def _remove_sync(): if not project: return "not_found" success = remove_project_member(client, project_id=project_id, user_sub=user_sub) - return "ok" if success else "last_member" + return "ok" if success else "remove_failed" result = await asyncio.to_thread(_remove_sync) if result == "not_found": return JSONResponse({"error": "not_found"}, status_code=404) - if result == "last_member": - return JSONResponse({"error": "cannot_remove_last_member"}, status_code=400) + if result == "remove_failed": + return JSONResponse({"error": "cannot_remove_member"}, status_code=400) return JSONResponse({"ok": True}) From cfa460f156abf9cfd6a072336f16e69fd3087b6c Mon Sep 17 00:00:00 2001 From: Finn Date: Fri, 13 Feb 2026 10:17:17 +0100 Subject: [PATCH 16/16] Update src/runtimes/ws_server.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/runtimes/ws_server.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/runtimes/ws_server.py b/src/runtimes/ws_server.py index 72c27b0..4bb9f64 100644 --- a/src/runtimes/ws_server.py +++ b/src/runtimes/ws_server.py @@ -1268,13 +1268,15 @@ def _remove_sync(): if not project: return "not_found" success = remove_project_member_by_email(client, project_id=project_id, user_email=user_email) - return "ok" if success else "last_member" + # The underlying store only tells us whether a removal happened, not why it failed. + # Do not assume every failure is due to "last member"; report a generic failure instead. + return "ok" if success else "cannot_remove_member" result = await asyncio.to_thread(_remove_sync) if result == "not_found": return JSONResponse({"error": "not_found"}, status_code=404) - if result == "last_member": - return JSONResponse({"error": "cannot_remove_last_member"}, status_code=400) + if result == "cannot_remove_member": + return JSONResponse({"error": "cannot_remove_member"}, status_code=400) return JSONResponse({"ok": True})