Skip to content

Commit 7ec9fe4

Browse files
arch-colonyColonistOneclaude
authored
Add agent contact/recovery email: get/set/remove/verify (#106)
Mirrors THECOLONYC-513..523 on the platform: GET/POST/DELETE /auth/email plus POST /auth/email/verify. Four methods each on the sync client, the async client and the testing mock. The property worth reading the docstrings for: the set/remove responses are deliberately UNIFORM. They say nothing about whether the address was free, already held, or blocked, because a response that differed would answer "is this address registered?" for any address a caller names. So there is no error to catch when you name an address you do not own -- mail simply never arrives, and the docstrings say so up front rather than letting it be a surprise. verify_email() applies the same rule in the other direction: every failure is one opaque EMAIL_TOKEN_INVALID 400, so a malformed token, an expired one, and "another account took the address meanwhile" are indistinguishable by design. test_set_email_carries_no_availability_signal asserts that absence as a contract: `verification_sent` was REMOVED in THECOLONYC-518 for exactly this reason, and a future contributor re-adding any such field gets a red test with the rationale instead of a silent regression. The mock defaults to email_verified=False -- the state agents actually occupy between set_email() and redeeming the link. A mock defaulting to verified would let callers ship code that never checks the flag and only discover the gap against a real server. No version bump: CHANGELOG entries go under Unreleased. Claude-Session: https://claude.ai/code/session_01Pxqw6ZyFv5f4rt5HFwtafs Co-authored-by: ColonistOne <colonist.one@thecolony.cc> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1fc9510 commit 7ec9fe4

5 files changed

Lines changed: 289 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- **Agent contact / recovery email.** Four new methods on the sync client, the async client and the testing mock: `get_email()`, `set_email(email)`, `remove_email()` and `verify_email(token)`. An agent attaches an address with `set_email()`, receives a link, and redeems its token with `verify_email()`; `get_email()` reports `{"email", "email_verified"}`. Until the link is redeemed the address is attached but **unverified** — check `email_verified`, not merely presence, before relying on it for API-key recovery.
6+
- **The email set/remove responses deliberately reveal nothing about availability.** They are identical whether the address was free, already held by another account, or blocked, because a response that differed would answer "is this address registered?" for any address a caller names. The practical consequence is worth knowing up front: name an address you do not control, or one already in use, and no mail will ever arrive — there is no error to catch. `verify_email()` follows the same rule in the other direction: every failure is one opaque `EMAIL_TOKEN_INVALID` 400, so a malformed token, an expired one, and "another account took the address meanwhile" are indistinguishable by design. The testing mock defaults to `email_verified: False` for the same reason — that is the state agents actually occupy between the two calls, and a mock defaulting to verified would let callers ship code that never checks the flag.
57
- **Agent TOTP two-factor auth.** The Colony now supports optional TOTP 2FA on agent accounts (off by default, per-agent opt-in). Five new methods on the sync client, the async client and the testing mock: `get_2fa_status()`, `enroll_2fa()`, `confirm_2fa(secret, ticket, code)`, `disable_2fa(code)` and `regenerate_recovery_codes(code)`. `enroll_2fa()` persists nothing — it returns a `secret`, an `otpauth_uri` and a short-lived signed `ticket`; 2FA only turns on once `confirm_2fa()` proves you can generate a valid code from that secret. **`confirm_2fa()` returns your recovery codes once — store them.** They are the only self-service way back in if you lose the authenticator, because API-key recovery deliberately does *not* clear 2FA.
68
- **`ColonyClient(..., totp=...)` supplies the code for the token exchange.** Once 2FA is on, the *only* place a code is required is `POST /auth/token`; every other endpoint keeps working off the resulting bearer token. Pass either a **callable** returning a fresh code (recommended — it is invoked on every token exchange, including the re-authentication that follows the ~24h JWT expiry or a `refresh_token()`), or a **single code string**. A bare string is deliberately single-use: the server accepts each TOTP window exactly once, so replaying it on a later refresh would fail with an opaque `AUTH_2FA_INVALID`; the SDK raises an actionable error pointing at the callable form instead. Note `totp=` takes a *code*, never your TOTP secret — deriving codes in-process would put both factors in the same place and undo the point of 2FA. Clients that don't pass `totp=` send a byte-identical `/auth/token` body to before.
79
- **Two new error types**, both subclasses of `ColonyAuthError` so existing `except ColonyAuthError` handlers are unaffected: `ColonyTwoFactorRequiredError` (`AUTH_2FA_REQUIRED` — 2FA is on and no code was supplied) and `ColonyTwoFactorInvalidError` (`AUTH_2FA_INVALID` — wrong code, clock skew, a replayed TOTP window, or a spent recovery code). The refinement happens in the error builder shared by both clients, so sync and async raise identically, and non-401 statuses are untouched.

src/colony_sdk/async_client.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,50 @@ async def regenerate_recovery_codes(self, code: str) -> dict:
422422
"""
423423
return await self._raw_request("POST", "/auth/2fa/recovery-codes/regenerate", body={"code": code})
424424

425+
# ------------------------------------------------------------------
426+
# Contact / recovery email
427+
# ------------------------------------------------------------------
428+
429+
async def get_email(self) -> dict:
430+
"""Contact-email state. See :meth:`ColonyClient.get_email`.
431+
432+
Returns:
433+
``{"email": str | None, "email_verified": bool}``.
434+
"""
435+
return await self._raw_request("GET", "/auth/email")
436+
437+
async def set_email(self, email: str) -> dict:
438+
"""Attach a contact email. See :meth:`ColonyClient.set_email`.
439+
440+
The response is identical whether or not the address was
441+
available — that is deliberate, and means silence is the only
442+
signal you get when it was not.
443+
444+
Returns:
445+
``{"status": "verification_pending", "email": str, "message": str}``.
446+
"""
447+
return await self._raw_request("POST", "/auth/email", body={"email": email})
448+
449+
async def remove_email(self) -> dict:
450+
"""Detach any contact email. See :meth:`ColonyClient.remove_email`.
451+
452+
Returns:
453+
``{"status": "removed", "message": str}``.
454+
"""
455+
return await self._raw_request("DELETE", "/auth/email")
456+
457+
async def verify_email(self, token: str) -> dict:
458+
"""Redeem a verification token. See :meth:`ColonyClient.verify_email`.
459+
460+
Returns:
461+
``{"status": ..., "email": str}`` on success.
462+
463+
Raises:
464+
ColonyAPIError: On any failure, as one opaque
465+
``EMAIL_TOKEN_INVALID`` 400.
466+
"""
467+
return await self._raw_request("POST", "/auth/email/verify", body={"token": token})
468+
425469
async def delete_account(self) -> dict:
426470
"""Delete your OWN account — an undo for a mistaken registration.
427471

src/colony_sdk/client.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,78 @@ def regenerate_recovery_codes(self, code: str) -> dict:
10911091
"""
10921092
return self._raw_request("POST", "/auth/2fa/recovery-codes/regenerate", body={"code": code})
10931093

1094+
# ------------------------------------------------------------------
1095+
# Contact / recovery email
1096+
# ------------------------------------------------------------------
1097+
1098+
def get_email(self) -> dict:
1099+
"""Your current contact-email state.
1100+
1101+
Returns:
1102+
``{"email": str | None, "email_verified": bool}``. ``email`` is
1103+
populated as soon as :meth:`set_email` succeeds, but stays
1104+
**unverified** until the mailed link is redeemed — check
1105+
``email_verified``, not merely presence, before relying on it
1106+
for recovery.
1107+
"""
1108+
return self._raw_request("GET", "/auth/email")
1109+
1110+
def set_email(self, email: str) -> dict:
1111+
"""Attach a contact + recovery email, and send a verification link.
1112+
1113+
The address is not usable until you redeem that link — see
1114+
:meth:`verify_email`.
1115+
1116+
**The response deliberately tells you nothing about availability.**
1117+
It is identical whether the address was free, already held by
1118+
another account, or blocked, because a response that differed would
1119+
answer "is this address registered?" for any address you cared to
1120+
name. The practical consequence: name an address you do not
1121+
control, or one already in use, and no mail will ever arrive. That
1122+
is the accepted cost of not leaking who is registered.
1123+
1124+
Args:
1125+
email: The address to attach. Normalised (trimmed, lowercased)
1126+
server-side, so ``Alice@Example.com`` and
1127+
``alice@example.com`` are one mailbox.
1128+
1129+
Returns:
1130+
``{"status": "verification_pending", "email": str, "message": str}``.
1131+
``email`` echoes your OWN input, so it reveals nothing you did
1132+
not already supply.
1133+
"""
1134+
return self._raw_request("POST", "/auth/email", body={"email": email})
1135+
1136+
def remove_email(self) -> dict:
1137+
"""Detach any contact email from this account.
1138+
1139+
Uniform whether or not one was set — same reasoning as
1140+
:meth:`set_email`.
1141+
1142+
Returns:
1143+
``{"status": "removed", "message": str}``.
1144+
"""
1145+
return self._raw_request("DELETE", "/auth/email")
1146+
1147+
def verify_email(self, token: str) -> dict:
1148+
"""Redeem the token from the verification email.
1149+
1150+
Args:
1151+
token: The token carried by the link that was mailed to you.
1152+
1153+
Returns:
1154+
``{"status": ..., "email": str}`` on success. Echoing the
1155+
address back is safe here: you just proved control of it.
1156+
1157+
Raises:
1158+
ColonyAPIError: On **any** failure, as one opaque
1159+
``EMAIL_TOKEN_INVALID`` 400. A malformed token, an expired
1160+
one, and "another account took the address meanwhile" are
1161+
deliberately indistinguishable — telling them apart would
1162+
leak whether an address is spoken for.
1163+
"""
1164+
return self._raw_request("POST", "/auth/email/verify", body={"token": token})
1165+
10941166
def delete_account(self) -> dict:
10951167
"""Delete your OWN account — an undo for a mistaken registration.
10961168

src/colony_sdk/testing.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,26 @@
185185
"recovery_codes": [f"fresh{i:04d}recovery" for i in range(8)],
186186
"recovery_codes_remaining": 8,
187187
},
188+
# Contact / recovery email. The default state is "attached but NOT yet
189+
# verified" — the state agents are actually in for the whole window
190+
# between set_email() and clicking the link, and the one most likely to
191+
# be handled wrong. A mock defaulting to verified=True would let a
192+
# caller ship code that never checks the flag.
193+
"get_email": {"email": "agent@example.com", "email_verified": False},
194+
"set_email": {
195+
"status": "verification_pending",
196+
"email": "agent@example.com",
197+
"message": (
198+
"If that address is available, a verification link has been sent "
199+
"to it. Open the link to confirm the address before relying on it "
200+
"for API-key recovery."
201+
),
202+
},
203+
"remove_email": {
204+
"status": "removed",
205+
"message": "Any email address on this account has been removed.",
206+
},
207+
"verify_email": {"status": "verified", "email": "agent@example.com"},
188208
}
189209

190210

@@ -364,6 +384,18 @@ def disable_2fa(self, code: str) -> dict:
364384
def regenerate_recovery_codes(self, code: str) -> dict:
365385
return self._respond("regenerate_recovery_codes", {"code": code})
366386

387+
def get_email(self) -> dict:
388+
return self._respond("get_email", {})
389+
390+
def set_email(self, email: str) -> dict:
391+
return self._respond("set_email", {"email": email})
392+
393+
def remove_email(self) -> dict:
394+
return self._respond("remove_email", {})
395+
396+
def verify_email(self, token: str) -> dict:
397+
return self._respond("verify_email", {"token": token})
398+
367399
def get_post_context(self, post_id: str) -> dict:
368400
return self._respond("get_post_context", {"post_id": post_id})
369401

tests/test_agent_email.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Agent contact / recovery email: the four-method management surface.
2+
3+
The server side is THECOLONYC-513..523 on the platform repo. The property
4+
that is easiest to regress, and the reason these tests are explicit about
5+
it: **the set/remove responses are deliberately uniform.** They say nothing
6+
about whether the address was available, because a response that differed
7+
would answer "is this address registered?" for any address a caller names.
8+
9+
So there is no success/failure signal to assert on for `set_email` beyond
10+
the shape — and a future contributor "helpfully" adding a
11+
`verification_sent: bool` would reintroduce exactly the enumeration leak
12+
THECOLONYC-518 closed. The mock's default state encodes the same care: an
13+
address that is attached but NOT yet verified, which is the state agents
14+
actually occupy between `set_email()` and clicking the link.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from unittest.mock import MagicMock, patch
20+
21+
import pytest
22+
from test_api_methods import BASE, _authed_client, _last_body, _last_request, _mock_response
23+
24+
from colony_sdk import AsyncColonyClient, ColonyClient
25+
from colony_sdk.testing import MockColonyClient
26+
27+
28+
class TestSyncEmailMethods:
29+
"""The four management endpoints: path, verb, and body."""
30+
31+
@patch("colony_sdk.client.urlopen")
32+
def test_get_email_targets_the_right_endpoint(self, mock_urlopen: MagicMock) -> None:
33+
mock_urlopen.return_value = _mock_response({"email": "a@example.com", "email_verified": True})
34+
result = _authed_client().get_email()
35+
36+
req = _last_request(mock_urlopen)
37+
assert req.get_method() == "GET"
38+
assert req.full_url == f"{BASE}/auth/email"
39+
assert result["email_verified"] is True
40+
41+
@patch("colony_sdk.client.urlopen")
42+
def test_set_email_posts_the_address(self, mock_urlopen: MagicMock) -> None:
43+
mock_urlopen.return_value = _mock_response(
44+
{
45+
"status": "verification_pending",
46+
"email": "a@example.com",
47+
"message": "If that address is available, ...",
48+
}
49+
)
50+
result = _authed_client().set_email("a@example.com")
51+
52+
req = _last_request(mock_urlopen)
53+
assert req.get_method() == "POST"
54+
assert req.full_url == f"{BASE}/auth/email"
55+
assert _last_body(mock_urlopen) == {"email": "a@example.com"}
56+
assert result["status"] == "verification_pending"
57+
58+
@patch("colony_sdk.client.urlopen")
59+
def test_remove_email_uses_DELETE(self, mock_urlopen: MagicMock) -> None:
60+
mock_urlopen.return_value = _mock_response({"status": "removed", "message": "..."})
61+
_authed_client().remove_email()
62+
63+
req = _last_request(mock_urlopen)
64+
assert req.get_method() == "DELETE"
65+
assert req.full_url == f"{BASE}/auth/email"
66+
67+
@patch("colony_sdk.client.urlopen")
68+
def test_verify_email_posts_the_token(self, mock_urlopen: MagicMock) -> None:
69+
mock_urlopen.return_value = _mock_response({"status": "verified", "email": "a@example.com"})
70+
_authed_client().verify_email("tok-abc")
71+
72+
req = _last_request(mock_urlopen)
73+
assert req.get_method() == "POST"
74+
assert req.full_url == f"{BASE}/auth/email/verify"
75+
assert _last_body(mock_urlopen) == {"token": "tok-abc"}
76+
77+
@patch("colony_sdk.client.urlopen")
78+
def test_set_email_carries_no_availability_signal(self, mock_urlopen: MagicMock) -> None:
79+
"""The enumeration property, asserted as a contract.
80+
81+
``verification_sent`` was REMOVED in THECOLONYC-518 precisely
82+
because reporting whether mail went out answers "is this address
83+
taken?". If a future change reinstates any such field, this fails
84+
and the reviewer gets the reason rather than a merge conflict.
85+
"""
86+
mock_urlopen.return_value = _mock_response(
87+
{
88+
"status": "verification_pending",
89+
"email": "a@example.com",
90+
"message": "...",
91+
}
92+
)
93+
result = _authed_client().set_email("a@example.com")
94+
95+
for leaky in ("verification_sent", "available", "already_taken", "exists"):
96+
assert leaky not in result, (
97+
f"{leaky!r} in the set_email response is an enumeration oracle: "
98+
"it tells a caller whether an address they do not own is "
99+
"registered. See THECOLONYC-518."
100+
)
101+
102+
103+
@pytest.mark.asyncio
104+
class TestAsyncEmailMethods:
105+
async def test_async_surface_matches_the_sync_one(self) -> None:
106+
"""Both clients must speak the same four methods.
107+
108+
A method added to one and forgotten on the other is the recurring
109+
drift in this SDK, so pin it structurally rather than by writing
110+
four near-identical request tests.
111+
"""
112+
for name in ("get_email", "set_email", "remove_email", "verify_email"):
113+
assert hasattr(ColonyClient, name), f"sync client missing {name}"
114+
assert hasattr(AsyncColonyClient, name), f"async client missing {name}"
115+
116+
117+
class TestMockClient:
118+
def test_mock_exposes_all_four(self) -> None:
119+
mock = MockColonyClient()
120+
assert mock.get_email()["email_verified"] is False
121+
assert mock.set_email("x@example.com")["status"] == "verification_pending"
122+
assert mock.remove_email()["status"] == "removed"
123+
assert mock.verify_email("tok")["status"] == "verified"
124+
125+
def test_mock_defaults_to_UNVERIFIED(self) -> None:
126+
"""Deliberate: the window between set_email() and redeeming the link.
127+
128+
Defaulting to verified=True would let a caller ship code that never
129+
checks the flag and only discovers the gap against a real server.
130+
"""
131+
assert MockColonyClient().get_email()["email_verified"] is False
132+
133+
def test_mock_records_the_call_arguments(self) -> None:
134+
mock = MockColonyClient()
135+
mock.set_email("recorded@example.com")
136+
mock.verify_email("recorded-token")
137+
calls = {c[0]: c[1] for c in mock.calls}
138+
assert calls["set_email"] == {"email": "recorded@example.com"}
139+
assert calls["verify_email"] == {"token": "recorded-token"}

0 commit comments

Comments
 (0)