diff --git a/roar/publish_auth.py b/roar/publish_auth.py index f95629d8..831db165 100644 --- a/roar/publish_auth.py +++ b/roar/publish_auth.py @@ -31,6 +31,21 @@ class PublishAuthContext: ssh_auth_available: bool = False +def _scope_visibility(repo_scope: Any) -> str | None: + """Resolve the visibility for a scope_request from the active repo scope. + + ``public`` -> ``"public"`` (attributed); ``anonymous`` -> ``None`` so the + caller omits ``scope_request`` and the server uses the legacy anonymous + public scope; everything else (``private``/``project``/unset) -> ``"private"``. + """ + if repo_scope is not None: + if repo_scope.mode == "public": + return "public" + if repo_scope.mode == "anonymous": + return None + return "private" + + def load_publish_auth_context( start_dir: str | Path | None = None, *, @@ -95,17 +110,22 @@ def load_publish_auth_context( scope_request = { "owner_id": binding["owner_id"], "owner_type": binding["owner_type"], - "visibility": "private", + "visibility": _scope_visibility(repo_scope) or "private", } project_id = binding.get("project_id") if project_id: scope_request["project_id"] = project_id elif not allow_public_without_binding: - scope_request = { - "owner_resolution": "current_user", - "owner_type": "user", - "visibility": "private", - } + # Server-authoritative owner resolution; visibility follows the active + # repo scope. The anonymous scope yields None here so we omit + # scope_request entirely (server uses the legacy anonymous public scope). + visibility = _scope_visibility(repo_scope) + if visibility is not None: + scope_request = { + "owner_resolution": "current_user", + "owner_type": "user", + "visibility": visibility, + } return PublishAuthContext( access_token=access_token, diff --git a/tests/unit/test_publish_auth_context.py b/tests/unit/test_publish_auth_context.py index 235dad11..417067d3 100644 --- a/tests/unit/test_publish_auth_context.py +++ b/tests/unit/test_publish_auth_context.py @@ -120,6 +120,38 @@ def test_private_publish_without_binding_uses_current_user_scope_request( } +def test_public_scope_uses_current_user_public_scope_request(tmp_path: Path) -> None: + config_dir = tmp_path / ".roar" + config_dir.mkdir(parents=True) + (config_dir / "config.toml").write_text('[scope]\nmode = "public"\n', encoding="utf-8") + + with ( + patch("roar.publish_auth.load_auth_state", return_value=_auth_state()), + patch("roar.publish_auth._has_ssh_auth_credentials", return_value=False), + ): + context = load_publish_auth_context(start_dir=tmp_path, allow_public_without_binding=False) + + assert context.scope_request == { + "owner_resolution": "current_user", + "owner_type": "user", + "visibility": "public", + } + + +def test_anonymous_scope_omits_scope_request(tmp_path: Path) -> None: + config_dir = tmp_path / ".roar" + config_dir.mkdir(parents=True) + (config_dir / "config.toml").write_text('[scope]\nmode = "anonymous"\n', encoding="utf-8") + + with ( + patch("roar.publish_auth.load_auth_state", return_value=_auth_state()), + patch("roar.publish_auth._has_ssh_auth_credentials", return_value=False), + ): + context = load_publish_auth_context(start_dir=tmp_path, allow_public_without_binding=False) + + assert context.scope_request is None + + def test_project_bound_private_publish_keeps_project_scope_request(tmp_path: Path) -> None: config_dir = tmp_path / ".roar" config_dir.mkdir(parents=True)