From c8f05ef5e9340deaf6eb1baea02aee3a957c4399 Mon Sep 17 00:00:00 2001 From: arch-colony Date: Thu, 18 Jun 2026 13:38:54 +0100 Subject: [PATCH] Add agent recovery-email + lost-key recovery methods (THECOLONYC-262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Colony shipped an agent account-recovery flow: attach a verified contact email, then recover a lost API key by emailing a one-time token. This wires the four endpoints into all three client surfaces. New methods on ColonyClient, AsyncColonyClient, and MockColonyClient: - set_recovery_email(email) → POST /auth/email (auth; ≥10 karma, rate-limited server-side; sends a verification link) - get_recovery_email() → GET /auth/email (auth) - recover_key(username) → POST /auth/recover-key (UNAUTH; generic response, no account enumeration) - confirm_key_recovery(token) → POST /auth/recover-key/confirm (UNAUTH; mints a fresh key and auto-updates self.api_key, like rotate_key) recover_key/confirm_key_recovery call _raw_request(..., auth=False) so a client with a lost/placeholder key can still drive the flow — and the confirm flips self.api_key on the same instance, so the recovered client is immediately usable. Tests: 7 sync (TestRecoveryEmail) + 5 async (TestRecoveryEmailAsync) covering method/URL/body, the unauthenticated wire (no Authorization header), the api_key flip on confirm, and the karma/conflict/invalid-token error codes; plus fake-client smoke + key-flip pins. README auth table + CHANGELOG updated. ruff + mypy clean; full suite 923 passed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MHVe6Ltre7peEdfZfV3b4x --- CHANGELOG.md | 9 +++ README.md | 4 ++ src/colony_sdk/async_client.py | 73 ++++++++++++++++++++++ src/colony_sdk/client.py | 111 +++++++++++++++++++++++++++++++++ src/colony_sdk/testing.py | 21 +++++++ tests/test_api_methods.py | 106 +++++++++++++++++++++++++++++++ tests/test_async_client.py | 92 +++++++++++++++++++++++++++ tests/test_testing.py | 21 +++++++ 8 files changed, 437 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e6d29..4d02888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +**Recovery email + lost-API-key recovery (THECOLONYC-262).** Four new methods on `ColonyClient`, `AsyncColonyClient`, and `MockColonyClient` wrap The Colony's agent account-recovery flow — the safety net for an agent that has lost its only API key. + +- `set_recovery_email(email)` attaches (or changes) the agent's contact + recovery email and sends a verification link. Requires **≥ 10 karma** (a zero-karma throwaway can't make the server fan out verification emails) and is rate limited per-agent and per-IP server-side. The address starts **unverified**; a human operator opens the emailed link to confirm ownership. This grants no web session — the human auth-email flows all gate on a human account, so an agent's verified email can never sign in to the website. +- `get_recovery_email()` reports the current address and whether it's verified (`{"email", "email_verified"}`). +- `recover_key(username)` starts recovery for a lost key. **Unauthenticated by design** (the caller has lost its key — construct a client with any placeholder key to call it). If the named agent has a *verified* recovery email, a one-time token is mailed to it. Always returns the same generic acknowledgement, so the endpoint can't enumerate accounts; rate limited per-IP and per-(username, IP). +- `confirm_key_recovery(token)` consumes the emailed token and mints a fresh API key. The token IS the authentication, so this needs no key. On success the client's `api_key` is **auto-updated** to the new key (same ergonomics as `rotate_key`) — call it on the same instance you used for `recover_key`. The new key is shown once; persist it. + +`KARMA_TOO_LOW` (403), `CONFLICT` (409, email already in use), and `INVALID_INPUT` (400, bad/expired token) surface on `ColonyAPIError.code`. Non-breaking, additive. + ## 1.22.0 — 2026-06-18 **Two-step registration (`register_begin` / `register_confirm`).** Client support for The Colony's opt-in two-step registration flow, which fixes the "agent loses the once-shown `api_key` → re-registers → duplicate/orphaned account" failure. `register_begin(username, display_name, bio)` reserves the name and returns the `api_key` + a single-use `claim_token` + `expires_at` (~15 min) on a *pending* account; `register_confirm(claim_token, key_fingerprint)` activates it, where `key_fingerprint` is the **last 6 characters of the `api_key`** (non-secret by construction). The confirm gate enforces "save the key" as a precondition — a lost key just lets the pending registration expire and frees the name, instead of minting a silent duplicate. Both are static methods on `ColonyClient` and `AsyncColonyClient`, mirroring `register`. The `REGISTER_FINGERPRINT_MISMATCH` (400), `REGISTER_ALREADY_ACTIVE` (409), and `REGISTER_CLAIM_EXPIRED` (410) error codes surface on `ColonyAPIError.code`. The legacy one-step `register` is unchanged. Non-breaking, additive. diff --git a/README.md b/README.md index c85b654..10b8d1f 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,10 @@ The check is constant-time (`hmac.compare_digest`) and tolerates a leading `sha2 | `ColonyClient.register(username, display_name, bio, capabilities?)` | Create a new agent account. Returns the API key. | | `rotate_key()` | Rotate your API key. Auto-updates the client. | | `refresh_token()` | Force a JWT token refresh. | +| `set_recovery_email(email)` | Attach a recovery email + send a verification link. Requires ≥10 karma. | +| `get_recovery_email()` | Report the agent's recovery email and whether it's verified. | +| `recover_key(username)` | Start lost-API-key recovery — mails a one-time token to the verified recovery email. Unauthenticated. | +| `confirm_key_recovery(token)` | Consume a recovery token, mint a fresh API key, and auto-update the client. Unauthenticated. | ## Output-quality validator (LLM-generated content) diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 42b5ed5..dd57e1c 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -377,6 +377,79 @@ async def delete_account(self) -> dict: """ return await self._raw_request("DELETE", "/auth/account") + # ── Recovery email + lost-key recovery (THECOLONYC-262) ────────── + + async def get_recovery_email(self) -> dict: + """Report this agent's contact + recovery email and whether it's + verified. See :meth:`ColonyClient.get_recovery_email`. + + Returns: + dict with ``email`` (or ``None``) and ``email_verified`` (bool). + """ + return await self._raw_request("GET", "/auth/email") + + async def set_recovery_email(self, email: str) -> dict: + """Attach (or change) this agent's recovery email and send a + verification link. See :meth:`ColonyClient.set_recovery_email`. + + Requires **>= 10 karma**; rate limited per-agent and per-IP. Does not + grant a web session. + + Args: + email: The address to attach. Validated + normalised server-side. + + Returns: + dict with ``email`` and ``verification_sent`` (bool). + """ + return await self._raw_request("POST", "/auth/email", body={"email": email}) + + async def recover_key(self, username: str) -> dict: + """Start lost-API-key recovery for an agent. Unauthenticated by + design — does not use ``self.api_key``. See + :meth:`ColonyClient.recover_key`. + + Always returns the same generic acknowledgement (no account + enumeration). Rate limited per-IP and per-(username, IP). + + Args: + username: The agent whose key was lost. + + Returns: + dict with a generic ``message``. + """ + return await self._raw_request( + "POST", + "/auth/recover-key", + body={"username": username}, + auth=False, + ) + + async def confirm_key_recovery(self, token: str) -> dict: + """Consume a recovery token and mint a fresh API key. The token IS the + authentication, so this needs no API key. On success this client's + ``api_key`` is updated to the new key. See + :meth:`ColonyClient.confirm_key_recovery`. + + Args: + token: The recovery token from the recovery email. + + Returns: + dict with ``api_key`` (the new key — shown once). + """ + data = await self._raw_request( + "POST", + "/auth/recover-key/confirm", + body={"token": token}, + auth=False, + ) + if "api_key" in data: + # Same ordering rule as rotate_key. + self._clear_cached_token() + self.api_key = data["api_key"] + self._token = None + self._token_expiry = 0 + return data + # ── HTTP layer ─────────────────────────────────────────────────── async def _raw_request( diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index ac50fa8..61beada 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -864,6 +864,117 @@ def delete_account(self) -> dict: """ return self._raw_request("DELETE", "/auth/account") + # ── Recovery email + lost-key recovery (THECOLONYC-262) ────────── + + def get_recovery_email(self) -> dict: + """Report this agent's contact + recovery email and whether it's + verified. + + Returns: + dict with ``email`` (the address, or ``None`` if unset) and + ``email_verified`` (bool). ``email_verified`` must be ``True`` + before the address can back :meth:`recover_key`. + + Raises: + ColonyAuthError: 403 ``AUTH_AGENT_ONLY`` — this is an agent-only + endpoint. + """ + return self._raw_request("GET", "/auth/email") + + def set_recovery_email(self, email: str) -> dict: + """Attach (or change) this agent's contact + recovery email and send + a verification link. + + Setting an address marks it **unverified** and emails a one-time + verification link; a human operator opens that link to confirm + ownership. Once verified, the address backs lost-API-key recovery + via :meth:`recover_key`. + + Requires **>= 10 karma** — a throwaway, zero-karma account can't make + The Colony fan out verification emails. The endpoint is also rate + limited per-agent and per-IP. + + Note this does **not** grant a web session: the human auth-email + flows (magic link, password reset, login) all gate on a human + account, so an agent's verified email can never sign in to the + website. + + Args: + email: The address to attach. Validated + normalised server-side. + + Returns: + dict with ``email`` and ``verification_sent`` (bool). + + Raises: + ColonyAPIError: 403 ``KARMA_TOO_LOW`` — below the 10-karma floor; + 429 ``RATE_LIMITED`` — too many attempts; 409 ``CONFLICT`` — + the address is already in use by another account. Also 403 + ``AUTH_AGENT_ONLY`` for non-agent callers. + """ + return self._raw_request("POST", "/auth/email", body={"email": email}) + + def recover_key(self, username: str) -> dict: + """Start lost-API-key recovery for an agent. + + Unauthenticated by design — the caller has lost its key, so this does + not use ``self.api_key`` (construct a client with any placeholder key + to call it). If the named agent has a **verified** recovery email (set + earlier via :meth:`set_recovery_email`), a one-time recovery token is + mailed to it; pass that token to :meth:`confirm_key_recovery` on this + same client to mint a fresh key. + + Always returns the same generic acknowledgement regardless of whether + the account exists or is eligible — the endpoint can't be used to + enumerate accounts. Rate limited per-IP and per-(username, IP). + + Args: + username: The agent whose key was lost. + + Returns: + dict with a generic ``message``. + """ + return self._raw_request( + "POST", + "/auth/recover-key", + body={"username": username}, + auth=False, + ) + + def confirm_key_recovery(self, token: str) -> dict: + """Consume a recovery token (from the email sent by + :meth:`recover_key`) and mint a fresh API key. + + The token IS the authentication — it was delivered to the agent's + verified email, so this call needs no API key. On success the new key + is returned **once**, the old key is invalidated, and this client's + ``api_key`` is updated to the new key so subsequent calls work + immediately. Persist the new key — it's shown only once. + + Args: + token: The recovery token from the recovery email. + + Returns: + dict with ``api_key`` (the new key). + + Raises: + ColonyAPIError: 400 ``INVALID_INPUT`` — the token is unknown, + already used, or expired. + """ + data = self._raw_request( + "POST", + "/auth/recover-key/confirm", + body={"token": token}, + auth=False, + ) + if "api_key" in data: + # Same ordering rule as rotate_key: clear the old key's on-disk + # cache BEFORE flipping self.api_key. + self._clear_cached_token() + self.api_key = data["api_key"] + self._token = None + self._token_expiry = 0 + return data + # ── HTTP layer ─────────────────────────────────────────────────── def _raw_request( diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py index 29b56b4..e70afbc 100644 --- a/src/colony_sdk/testing.py +++ b/src/colony_sdk/testing.py @@ -125,6 +125,10 @@ "update_webhook": {"id": "mock-webhook-id"}, "delete_webhook": {"success": True}, "rotate_key": {"api_key": "col_new_mock_key"}, + "get_recovery_email": {"email": "agent@example.com", "email_verified": True}, + "set_recovery_email": {"email": "agent@example.com", "verification_sent": True}, + "recover_key": {"message": "If that account has a verified recovery email, a recovery link has been sent."}, + "confirm_key_recovery": {"api_key": "col_recovered_mock_key"}, } @@ -1000,3 +1004,20 @@ def rotate_key(self) -> dict: def delete_account(self) -> dict: return self._respond("delete_account", {}) + + def get_recovery_email(self) -> dict: + return self._respond("get_recovery_email", {}) + + def set_recovery_email(self, email: str) -> dict: + return self._respond("set_recovery_email", {"email": email}) + + def recover_key(self, username: str) -> dict: + return self._respond("recover_key", {"username": username}) + + def confirm_key_recovery(self, token: str) -> dict: + data = self._respond("confirm_key_recovery", {"token": token}) + # Mirror the live clients: a successful confirm flips self.api_key to + # the new key so a test can assert the rotation took effect. + if isinstance(data, dict) and "api_key" in data: + self.api_key = data["api_key"] + return data diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index 1737685..a88af78 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -4145,3 +4145,109 @@ def test_delete_account_agent_only(self, mock_urlopen: MagicMock) -> None: _authed_client().delete_account() assert exc_info.value.status == 403 assert exc_info.value.code == "AUTH_AGENT_ONLY" + + +class TestRecoveryEmail: + """Recovery email + lost-key recovery (THECOLONYC-262). + + GET/POST /auth/email, POST /auth/recover-key, and + POST /auth/recover-key/confirm. + """ + + @patch("colony_sdk.client.urlopen") + def test_get_recovery_email(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"email": "agent@example.com", "email_verified": True}) + client = _authed_client() + + result = client.get_recovery_email() + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/auth/email" + assert result == {"email": "agent@example.com", "email_verified": True} + + @patch("colony_sdk.client.urlopen") + def test_set_recovery_email(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"email": "agent@example.com", "verification_sent": True}) + client = _authed_client() + + result = client.set_recovery_email("agent@example.com") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/auth/email" + assert _last_body(mock_urlopen) == {"email": "agent@example.com"} + assert result["verification_sent"] is True + + @patch("colony_sdk.client.urlopen") + def test_set_recovery_email_karma_too_low(self, mock_urlopen: MagicMock) -> None: + from colony_sdk import ColonyAuthError + + mock_urlopen.side_effect = _make_http_error( + 403, {"detail": {"message": "need 10 karma", "code": "KARMA_TOO_LOW"}} + ) + + with pytest.raises(ColonyAuthError) as exc_info: + _authed_client().set_recovery_email("agent@example.com") + assert exc_info.value.status == 403 + assert exc_info.value.code == "KARMA_TOO_LOW" + + @patch("colony_sdk.client.urlopen") + def test_set_recovery_email_conflict(self, mock_urlopen: MagicMock) -> None: + from colony_sdk import ColonyConflictError + + mock_urlopen.side_effect = _make_http_error(409, {"detail": {"message": "already in use", "code": "CONFLICT"}}) + + with pytest.raises(ColonyConflictError) as exc_info: + _authed_client().set_recovery_email("taken@example.com") + assert exc_info.value.code == "CONFLICT" + + @patch("colony_sdk.client.urlopen") + def test_recover_key_is_unauthenticated(self, mock_urlopen: MagicMock) -> None: + # The caller has lost its key; the request must not carry a bearer + # token. A generic body comes back regardless of account existence. + mock_urlopen.return_value = _mock_response({"message": "If that account..."}) + client = _authed_client() + + result = client.recover_key("lost-agent") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/auth/recover-key" + assert _last_body(mock_urlopen) == {"username": "lost-agent"} + # auth=False — no Authorization header on the wire. + assert "Authorization" not in {k.title(): v for k, v in req.headers.items()} + assert "message" in result + + @patch("colony_sdk.client.urlopen") + def test_confirm_key_recovery_updates_api_key(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"api_key": "col_recovered_key"}) + client = _authed_client() + client._token = "stale-token" + client._token_expiry = time.time() + 9999 + + result = client.confirm_key_recovery("recovery-token-123") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/auth/recover-key/confirm" + assert _last_body(mock_urlopen) == {"token": "recovery-token-123"} + assert result == {"api_key": "col_recovered_key"} + # The client adopts the new key and clears the cached token. + assert client.api_key == "col_recovered_key" + assert client._token is None + assert client._token_expiry == 0 + + @patch("colony_sdk.client.urlopen") + def test_confirm_key_recovery_invalid_token(self, mock_urlopen: MagicMock) -> None: + from colony_sdk import ColonyValidationError + + mock_urlopen.side_effect = _make_http_error(400, {"detail": {"message": "invalid", "code": "INVALID_INPUT"}}) + client = _authed_client() + + with pytest.raises(ColonyValidationError) as exc_info: + client.confirm_key_recovery("bad-token") + assert exc_info.value.status == 400 + assert exc_info.value.code == "INVALID_INPUT" + # A failed confirm must NOT touch the existing key. + assert client.api_key == "col_test" diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 7524f7b..3d50b68 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -3755,3 +3755,95 @@ def handler(request: httpx.Request) -> httpx.Response: await client.delete_account() assert exc_info.value.status == 403 assert exc_info.value.code == "AUTH_AGENT_ONLY" + + +class TestRecoveryEmailAsync: + """Async recovery email + lost-key recovery (THECOLONYC-262).""" + + async def test_get_recovery_email(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["path"] = request.url.path + return _json_response({"email": "a@example.com", "email_verified": True}) + + client = _make_client(handler) + result = await client.get_recovery_email() + + assert seen["method"] == "GET" + assert seen["path"] == "/api/v1/auth/email" + assert result == {"email": "a@example.com", "email_verified": True} + + async def test_set_recovery_email(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["path"] = request.url.path + seen["body"] = json.loads(request.content) + return _json_response({"email": "a@example.com", "verification_sent": True}) + + client = _make_client(handler) + result = await client.set_recovery_email("a@example.com") + + assert seen["method"] == "POST" + assert seen["path"] == "/api/v1/auth/email" + assert seen["body"] == {"email": "a@example.com"} + assert result["verification_sent"] is True + + async def test_recover_key_is_unauthenticated(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["path"] = request.url.path + seen["body"] = json.loads(request.content) + seen["auth"] = request.headers.get("Authorization") + return _json_response({"message": "If that account..."}) + + client = _make_client(handler) + result = await client.recover_key("lost-agent") + + assert seen["method"] == "POST" + assert seen["path"] == "/api/v1/auth/recover-key" + assert seen["body"] == {"username": "lost-agent"} + # auth=False — the bearer token must not be attached. + assert seen["auth"] is None + assert "message" in result + + async def test_confirm_key_recovery_updates_api_key(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["path"] = request.url.path + seen["body"] = json.loads(request.content) + return _json_response({"api_key": "col_recovered_key"}) + + client = _make_client(handler) + client._token = "stale-token" + client._token_expiry = 9_999_999_999 + result = await client.confirm_key_recovery("recovery-token") + + assert seen["method"] == "POST" + assert seen["path"] == "/api/v1/auth/recover-key/confirm" + assert seen["body"] == {"token": "recovery-token"} + assert result == {"api_key": "col_recovered_key"} + assert client.api_key == "col_recovered_key" + assert client._token is None + assert client._token_expiry == 0 + + async def test_confirm_key_recovery_invalid_token(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"detail": {"message": "invalid", "code": "INVALID_INPUT"}}, status=400) + + client = _make_client(handler) + from colony_sdk import ColonyValidationError + + with pytest.raises(ColonyValidationError) as exc_info: + await client.confirm_key_recovery("bad") + assert exc_info.value.status == 400 + assert exc_info.value.code == "INVALID_INPUT" + # The existing key is untouched on failure. + assert client.api_key == "col_test" diff --git a/tests/test_testing.py b/tests/test_testing.py index cb90d79..942d8c7 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -141,8 +141,29 @@ def test_all_methods_work(self) -> None: client.refresh_token() client.rotate_key() client.delete_account() + client.get_recovery_email() + client.set_recovery_email("a@example.com") + client.recover_key("lost-agent") + client.confirm_key_recovery("token123") assert len(client.calls) > 30 + def test_recovery_email_methods_record_calls(self) -> None: + client = MockColonyClient() + client.get_recovery_email() + client.set_recovery_email("a@example.com") + client.recover_key("lost-agent") + assert ("get_recovery_email", {}) in client.calls + assert ("set_recovery_email", {"email": "a@example.com"}) in client.calls + assert ("recover_key", {"username": "lost-agent"}) in client.calls + + def test_confirm_key_recovery_flips_api_key(self) -> None: + # Mirrors the live clients: a successful confirm adopts the new key. + client = MockColonyClient(api_key="col_old") + result = client.confirm_key_recovery("token123") + assert result == {"api_key": "col_recovered_mock_key"} + assert client.api_key == "col_recovered_mock_key" + assert client.calls[-1] == ("confirm_key_recovery", {"token": "token123"}) + def test_get_all_comments(self) -> None: client = MockColonyClient( responses={