From 5574faace0679cb67136668e1b59aa8e290b10c8 Mon Sep 17 00:00:00 2001 From: HumbleTurtle Date: Mon, 3 Aug 2026 01:16:15 -0300 Subject: [PATCH] Fix account login project creation lifecycle --- resources/scripts/nap_install.py | 2 +- resources/scripts/napseer_mcp_server.py | 61 ++++++++--- tests/test_cli_command_system.py | 2 +- tests/test_project_attach_oauth.py | 2 + tests/test_project_create_oauth.py | 133 ++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 tests/test_project_create_oauth.py diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index f784151..89c37b7 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -58,7 +58,7 @@ RUNTIME_SCRIPT_NAMES = ( "napseer_mcp_supervisor.py", ) MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024))) -CLI_RELEASE_VERSION = "0.2.1" +CLI_RELEASE_VERSION = "0.2.2" CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-08-01" CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19" CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1" diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index b8793f9..531becd 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -7025,13 +7025,23 @@ LOCAL_PROJECT_OAUTH_SCOPE = ( "napseer.gateway.read napseer.gateway.connect" ) +# Account login is also the first step of the new-project lifecycle. It needs +# project creation authority plus the capabilities that will be used after the +# newly created project is bound to this directory. Existing-project attach +# remains narrower and receives a server-bound project token. +OPERATOR_ACCOUNT_OAUTH_SCOPE = LOCAL_PROJECT_OAUTH_SCOPE.replace( + "napseer.projects.read ", + "napseer.projects.read napseer.projects.write ", + 1, +) + def operator_account_login(args=None): args = args or {} result = oauth_loopback_authorize({ **args, "flow": "operator_login", - "scope": args.get("scope") or "openid profile email napseer.projects.read", + "scope": args.get("scope") or OPERATOR_ACCOUNT_OAUTH_SCOPE, }) token_expires_at = iso_timestamp_after_seconds(int(result.get("expires_in") or 0)) replace_public_auth_state( @@ -7054,7 +7064,10 @@ def operator_account_login(args=None): "base_url": result.get("api_base_url") or BASE_URL, "scope": result.get("scope"), "token_expires_at": token_expires_at, - "message": "Account authenticated without a project binding. Run `nap project attach` to select a project for this directory.", + "message": ( + "Account authenticated without a project binding. Run `nap project attach` " + "for an existing project or `nap project create` for a new one." + ), } @@ -7551,6 +7564,18 @@ def current_project_id(): def create_project_with_state(args): ensure_enrolled(args) + refresh_public_auth_state() + if ( + AUTH.get("account_mode") == "operator_account" + and AUTH.get("oauth_scope") + and "napseer.projects.write" not in str(AUTH.get("oauth_scope")).split() + ): + raise SafeToolError( + "project_create_scope_required", + "This account login predates project-create permission. Run `nap auth login` " + "again, then retry `nap project create`.", + status=403, + ) payload = { "slug": args["slug"], "name": args["name"], @@ -7560,21 +7585,30 @@ def create_project_with_state(args): try: project = request_json("POST", "/v1/projects", payload) except RuntimeError as exc: + if safe_error_matches(exc, status=403, service_code="insufficient_scope"): + raise SafeToolError( + "project_create_scope_required", + "Project creation is not authorized by this account login. Run `nap auth login` " + "again, then retry `nap project create`.", + status=403, + service_code="insufficient_scope", + ) from exc if "HTTP 409" not in str(exc): raise project = find_project_by_slug(args["slug"]) if not project: raise status = "existing" - save_auth( - { - "base_url": BASE_URL, - "token": TOKEN, - "project_id": project["id"], - "project_slug": project["slug"], - "project_name": project["name"], - } - ) + auth_updates = { + "base_url": BASE_URL, + "token": TOKEN, + "project_id": project["id"], + "project_slug": project["slug"], + "project_name": project["name"], + } + if AUTH.get("account_mode") == "operator_account": + auth_updates["account_mode"] = "operator_project" + save_auth(auth_updates) try: register_project_signing_key({"label": "local-project-key"}) except Exception: @@ -8119,7 +8153,8 @@ def cli_configure_status(): "Project is configured for this directory." if project_configured else ( - "Account authenticated; no project is attached. Run `nap project attach`." + "Account authenticated; no project is attached. Run `nap project attach` " + "for an existing project or `nap project create` for a new one." if account_only else "No account or project is configured for this directory." ) @@ -14866,7 +14901,7 @@ def cli_main(argv): nap auth repair `login` signs in to the account only. It does not select a project. -Use `nap project attach` to bind this folder to an existing project. +Use `nap project attach` for an existing project or `nap project create` for a new one. Refresh is automatic during authenticated calls; use `repair` only for recovery. `login-local` remains a hidden compatibility alias for `login`.""") return diff --git a/tests/test_cli_command_system.py b/tests/test_cli_command_system.py index c0babae..c60f853 100644 --- a/tests/test_cli_command_system.py +++ b/tests/test_cli_command_system.py @@ -246,7 +246,7 @@ def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monk result = module.install_assets() - assert result["cli_distribution"]["release_version"] == "0.2.1" + assert result["cli_distribution"]["release_version"] == "0.2.2" assert (install_dir / "current").is_symlink() assert (install_dir / "current" / "bundle-manifest.json").is_file() assert (install_dir / "current" / "napseer_mcp_server.py").is_file() diff --git a/tests/test_project_attach_oauth.py b/tests/test_project_attach_oauth.py index b431313..d7c0c78 100644 --- a/tests/test_project_attach_oauth.py +++ b/tests/test_project_attach_oauth.py @@ -80,7 +80,9 @@ def run(): assert saves[0]["refresh_token"] == "account-refresh-token" assert saves[0]["account_mode"] == "operator_account" assert "project_id" not in saves[0] + assert "napseer.projects.write" in saves[0]["oauth_scope"] assert result["mode"] == "operator_account" + assert "nap project create" in result["message"] print("ok: project attach OAuth smoke passed") diff --git a/tests/test_project_create_oauth.py b/tests/test_project_create_oauth.py new file mode 100644 index 0000000..e98641b --- /dev/null +++ b/tests/test_project_create_oauth.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Regression tests for account-login -> project-create authorization.""" + +import importlib.util +import pathlib +import sys + + +PROJECT_ID = "33333333-3333-3333-3333-333333333333" + + +def load_module(): + script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py" + sys.path.insert(0, str(script_path.parent)) + spec = importlib.util.spec_from_file_location("napseer_mcp_server_project_create_oauth_test", script_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def configure_account_state(mod, scope): + mod.AUTH = { + "account_mode": "operator_account", + "oauth_scope": scope, + } + mod.TOKEN = "test-token" + mod.BASE_URL = "https://api.example.test" + mod.ensure_enrolled = lambda args: {"status": "already_enrolled"} + mod.refresh_public_auth_state = lambda: mod.AUTH + mod.register_project_signing_key = lambda args: None + + +def project_args(): + return { + "slug": "created-project", + "name": "Created Project", + "description": "", + } + + +def test_account_login_requests_create_and_runtime_scopes(): + mod = load_module() + captured = [] + saves = [] + mod.AUTH_PATH = pathlib.Path("/tmp/napseer-test/auth.json") + mod.oauth_loopback_authorize = lambda args: ( + captured.append(dict(args)) + or { + "api_base_url": "https://api.example.test", + "access_token": "test-token", + "refresh_token": "test-refresh-token", + "expires_in": 3600, + "scope": args["scope"], + } + ) + mod.replace_public_auth_state = lambda updates, clear_keys=(): saves.append(dict(updates)) + + result = mod.operator_account_login({"open_browser": False}) + + requested = set(captured[0]["scope"].split()) + assert "napseer.projects.write" in requested + assert set(mod.LOCAL_PROJECT_OAUTH_SCOPE.split()).issubset(requested) + assert saves[0]["account_mode"] == "operator_account" + assert "nap project create" in result["message"] + + +def test_legacy_account_scope_fails_before_http_with_recovery(): + mod = load_module() + configure_account_state(mod, "openid profile email napseer.projects.read") + requests = [] + mod.request_json = lambda *args, **kwargs: requests.append((args, kwargs)) + + try: + mod.create_project_with_state(project_args()) + except mod.SafeToolError as exc: + assert exc.code == "project_create_scope_required" + assert exc.status == 403 + assert "nap auth login" in exc.safe_message + else: + raise AssertionError("legacy account scope should require a new login") + + assert not requests + + +def test_successful_create_transitions_local_state_to_project_mode(): + mod = load_module() + configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE) + saves = [] + mod.request_json = lambda method, path, payload=None: { + "id": PROJECT_ID, + "slug": payload["slug"], + "name": payload["name"], + } + mod.save_auth = lambda updates: saves.append(dict(updates)) + + project, status, _message = mod.create_project_with_state(project_args()) + + assert status == "created" + assert project["id"] == PROJECT_ID + assert saves[0]["project_id"] == PROJECT_ID + assert saves[0]["account_mode"] == "operator_project" + + +def test_server_scope_denial_is_actionable(): + mod = load_module() + configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE) + + def denied(*args, **kwargs): + raise mod.SafeToolError( + "http_403", + "Napseer POST request failed with HTTP 403.", + status=403, + service_code="insufficient_scope", + ) + + mod.request_json = denied + + try: + mod.create_project_with_state(project_args()) + except mod.SafeToolError as exc: + assert exc.code == "project_create_scope_required" + assert "nap auth login" in exc.safe_message + else: + raise AssertionError("scope denial should return an actionable error") + + +if __name__ == "__main__": + test_account_login_requests_create_and_runtime_scopes() + test_legacy_account_scope_fails_before_http_with_recovery() + test_successful_create_transitions_local_state_to_project_mode() + test_server_scope_denial_is_actionable() + print("ok: project create OAuth lifecycle passed")