Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
73 changes: 73 additions & 0 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
111 changes: 111 additions & 0 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions src/colony_sdk/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}


Expand Down Expand Up @@ -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
106 changes: 106 additions & 0 deletions tests/test_api_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading