"""User-level forge connection CRUD — the keyring rows get_forges reads (#2778). A connection is a user's read-only credential for one forge host; one row per (user, host) keeps host-keyed resolution deterministic with no default-pointer machinery. Everything here is own-rows-only: a connection is a credential, and no caller — admin included — reads or edits another user's. The token never leaves the server (model.to_dict omits it; routes mask "set/unset"). Validation matches what build_adapter will accept, checked here so a bad value is a 400 at the form instead of a silently dead keyring row. """ from __future__ import annotations from sqlalchemy import select from scribe.models import async_session from scribe.models.forge_connection import ForgeConnection from scribe.models.project import Project from scribe.services.forge import FORGE_KINDS, host_of def validate_connection(kind: str, base_url: str) -> str | None: """The error a connection's non-secret values would earn, or None.""" if kind not in FORGE_KINDS: return f"Unknown forge kind {kind!r} (one of: {', '.join(FORGE_KINDS)})" if not base_url.startswith(("http://", "https://")): return "Forge base URL must use http or https" if not host_of(base_url): return "Forge base URL carries no hostname" return None async def list_connections(user_id: int) -> list[ForgeConnection]: async with async_session() as session: rows = await session.execute( select(ForgeConnection) .where(ForgeConnection.user_id == user_id) .order_by(ForgeConnection.host) ) return list(rows.scalars().all()) async def get_connection(user_id: int, connection_id: int) -> ForgeConnection | None: async with async_session() as session: return ( await session.execute( select(ForgeConnection).where( ForgeConnection.id == connection_id, ForgeConnection.user_id == user_id, ) ) ).scalar_one_or_none() async def create_connection( user_id: int, *, kind: str, base_url: str, token: str ) -> ForgeConnection: """Create a keyring row. Raises ValueError on bad values or a host the user already holds — one row per (user, host) IS the resolution model, so a second token for the same host is an update, not a create.""" kind = (kind or "").strip().lower() base_url = (base_url or "").strip().rstrip("/") token = token or "" error = validate_connection(kind, base_url) if error is None and not token: error = "A token is required (read scope is enough)" if error: raise ValueError(error) host = host_of(base_url) async with async_session() as session: existing = ( await session.execute( select(ForgeConnection).where( ForgeConnection.user_id == user_id, ForgeConnection.host == host, ) ) ).scalar_one_or_none() if existing is not None: raise ValueError( f"You already have a connection for {host} — edit that one; " "resolution is by host, so a second row could never be reached" ) row = ForgeConnection( user_id=user_id, kind=kind, base_url=base_url, host=host, token=token ) session.add(row) await session.commit() await session.refresh(row) return row async def update_connection( user_id: int, connection_id: int, *, kind: str = "", base_url: str = "", token: str = "", ) -> ForgeConnection | None: """Update own row; empty string = leave unchanged (the settings-form sentinel convention). None when the row isn't the caller's.""" async with async_session() as session: row = ( await session.execute( select(ForgeConnection).where( ForgeConnection.id == connection_id, ForgeConnection.user_id == user_id, ) ) ).scalar_one_or_none() if row is None: return None new_kind = (kind or "").strip().lower() or row.kind new_base = (base_url or "").strip().rstrip("/") or row.base_url error = validate_connection(new_kind, new_base) if error: raise ValueError(error) new_host = host_of(new_base) if new_host != row.host: clash = ( await session.execute( select(ForgeConnection.id).where( ForgeConnection.user_id == user_id, ForgeConnection.host == new_host, ForgeConnection.id != row.id, ) ) ).scalar_one_or_none() if clash is not None: raise ValueError( f"You already have a connection for {new_host} — edit that one" ) row.kind = new_kind row.base_url = new_base row.host = new_host if token: row.token = token await session.commit() await session.refresh(row) return row async def delete_connection(user_id: int, connection_id: int) -> bool: """Delete own row. Project pins pointing at it go NULL (FK SET NULL) — those projects fall back to keyring resolution, which is the documented unpinned behavior, not a surprise.""" async with async_session() as session: row = ( await session.execute( select(ForgeConnection).where( ForgeConnection.id == connection_id, ForgeConnection.user_id == user_id, ) ) ).scalar_one_or_none() if row is None: return False await session.delete(row) await session.commit() return True async def set_project_pin( owner_id: int, project_id: int, connection_id: int | None ) -> bool: """Point a project at one of its OWNER's connections, or clear the pin. The caller settles WHO may ask (routes check owner-or-admin); this settles WHOSE connection is eligible: only the project owner's — pinning a collaborator's token to someone else's project is the confused-deputy channel this feature exists to close. False = project or connection not eligible. """ async with async_session() as session: project = ( await session.execute(select(Project).where(Project.id == project_id)) ).scalar_one_or_none() if project is None or (project.user_id or 0) != owner_id: return False if connection_id: held = ( await session.execute( select(ForgeConnection.id).where( ForgeConnection.id == connection_id, ForgeConnection.user_id == owner_id, ) ) ).scalar_one_or_none() if held is None: return False project.forge_connection_id = connection_id or None await session.commit() return True