diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index ccba23f2..0bfa2436 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -530,8 +530,10 @@ export interface paths { * Classify Demo * @description Public classify endpoint gated by email only. * - * Returns a limited result set (5 systems, 3 results each). Records - * the email as a lead. + * Anonymous callers get the 5-system / 3-result anon tier. Callers + * with a valid `dev_session` cookie (i.e., they completed the + * magic-link sign-in) get the broader 10-system / 5-result tier. + * Records the email as a lead either way. * * Per-IP rate guard: 20/hour. The cap is well above any legitimate * interactive use (a user trying ~5 prompts in a session) but tight @@ -1060,6 +1062,12 @@ export interface components { * @default true */ demo: boolean; + /** + * Is Logged In + * @description True when the request carried a valid `dev_session` cookie. Logged-in callers get the broader 10-system / 5-result-per-system tier; anonymous callers get the 5-system / 3-result tier. + * @default false + */ + is_logged_in: boolean; /** Upgrade Cta */ upgrade_cta: string; /** @@ -2444,7 +2452,9 @@ export interface operations { query?: never; header?: never; path?: never; - cookie?: never; + cookie?: { + dev_session?: string | null; + }; }; requestBody: { content: { diff --git a/tests/test_magic_link.py b/tests/test_magic_link.py index 70beb5e4..e2f0cf5f 100644 --- a/tests/test_magic_link.py +++ b/tests/test_magic_link.py @@ -134,3 +134,102 @@ def test_no_op_email_client_when_unconfigured(self, capsys): client = NoopEmailClient() client.send(to="x@y.com", subject="s", html="

hi

", text="hi") # No exception is the contract; output is informational. + + +def _fake_http_response(body_bytes): + """Minimal context-manager stand-in for urllib's urlopen result.""" + + class _Resp: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return body_bytes + + return _Resp() + + +class TestResendClient: + """ResendClient posts a single request to api.resend.com. These + tests mock the HTTP layer, so no API key and no network are + needed.""" + + def test_send_posts_expected_resend_payload(self, monkeypatch): + import json + + from world_of_taxonomy.auth.email import ResendClient + + captured = {} + + def fake_urlopen(req, timeout=None): + captured["url"] = req.full_url + captured["method"] = req.get_method() + captured["body"] = json.loads(req.data.decode("utf-8")) + return _fake_http_response(b'{"id":"abc-123"}') + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + ResendClient(api_key="re_test", sender="noreply@aixcelerator.ai").send( + to="dev@acme.com", + subject="Sign in to WorldOfTaxonomy", + html="

link

", + text="link", + ) + + assert captured["url"] == "https://api.resend.com/emails" + assert captured["method"] == "POST" + body = captured["body"] + assert body["from"] == "noreply@aixcelerator.ai" + assert body["to"] == ["dev@acme.com"] + assert body["subject"] == "Sign in to WorldOfTaxonomy" + + def test_send_sets_explicit_user_agent(self, monkeypatch): + """api.resend.com sits behind Cloudflare, which blocks urllib's + default `Python-urllib/x.y` User-Agent with HTTP 403 ("error + code: 1010"). The client must send an explicit, non-default UA + or every magic-link email is silently dropped at the edge.""" + from world_of_taxonomy.auth.email import ResendClient + + captured = {} + + def fake_urlopen(req, timeout=None): + # urllib normalises header keys to "User-agent". + captured["ua"] = req.get_header("User-agent") + return _fake_http_response(b'{"id":"abc-123"}') + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + ResendClient(api_key="re_test").send( + to="x@y.com", subject="s", html="

h

", text="h", + ) + assert captured["ua"] + assert "python-urllib" not in captured["ua"].lower() + + def test_send_does_not_raise_on_http_error(self, monkeypatch, caplog): + """A 403 / bad key must be swallowed and logged with the response + body: signup cannot 500 on an email-infrastructure failure, and + the body is what distinguishes an API error from a Cloudflare + edge block.""" + import io + import urllib.error + + from world_of_taxonomy.auth.email import ResendClient + + def fake_urlopen(req, timeout=None): + raise urllib.error.HTTPError( + req.full_url, 403, "Forbidden", {}, + io.BytesIO(b"error code: 1010"), + ) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + with caplog.at_level("ERROR", logger="wot.auth.email"): + ResendClient(api_key="re_bad").send( + to="x@y.com", subject="s", html="

h

", text="h", + ) + messages = [r.getMessage() for r in caplog.records] + assert any("resend_send_failed" in m for m in messages) + assert any("1010" in m for m in messages) diff --git a/world_of_taxonomy/auth/email.py b/world_of_taxonomy/auth/email.py index 16dd1a1c..eada20f0 100644 --- a/world_of_taxonomy/auth/email.py +++ b/world_of_taxonomy/auth/email.py @@ -46,8 +46,9 @@ def __init__(self, api_key: str, sender: str = "noreply@aixcelerator.ai"): self._sender = sender def send(self, *, to: str, subject: str, html: str, text: str) -> None: - import urllib.request import json + import urllib.error + import urllib.request req = urllib.request.Request( "https://api.resend.com/emails", @@ -61,16 +62,27 @@ def send(self, *, to: str, subject: str, html: str, text: str) -> None: headers={ "Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json", + # An explicit User-Agent is required: api.resend.com sits + # behind Cloudflare, which blocks urllib's default + # `Python-urllib/x.y` UA with HTTP 403 ("error code: + # 1010") before the request ever reaches Resend's API - + # silently dropping every magic-link email. + "User-Agent": "WorldOfTaxonomy/1.0", }, method="POST", ) try: with urllib.request.urlopen(req, timeout=10) as response: - if response.status >= 400: - logger.error( - "resend_send_failed: status=%s body=%s", - response.status, response.read()[:500], - ) + response.read() + except urllib.error.HTTPError as exc: + # Resend returns a JSON error envelope on 4xx/5xx. Capturing + # the body matters: a bare "HTTP Error 403" hides whether the + # cause is the API (bad key / unverified domain) or the + # Cloudflare edge (blocked User-Agent -> "error code: 1010"). + detail = exc.read()[:500] if hasattr(exc, "read") else b"" + logger.error( + "resend_send_failed: status=%s body=%s", exc.code, detail, + ) except Exception as exc: logger.exception("resend_send_exception: %s", exc)