aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--jb/api/auth.py13
-rw-r--r--jb/config.py2
-rw-r--r--jb/decorators.py13
-rw-r--r--jb/flow/assignment_tasks.py6
-rw-r--r--jb/flow/monitoring.py2
-rw-r--r--jb/flow/tasks.py2
-rw-r--r--jb/managers/__init__.py2
-rw-r--r--jb/managers/amt.py2
-rw-r--r--jb/managers/gr_api.py112
-rw-r--r--jb/managers/thl.py12
-rw-r--r--jb/models/auth.py47
-rw-r--r--jb/models/bonus.py2
-rw-r--r--jb/models/hit.py2
-rw-r--r--jb/settings.py7
-rw-r--r--jb/views/auth.py36
-rw-r--r--requirements.txt9
16 files changed, 223 insertions, 46 deletions
diff --git a/jb/api/auth.py b/jb/api/auth.py
index 7d6c352..3481b30 100644
--- a/jb/api/auth.py
+++ b/jb/api/auth.py
@@ -8,13 +8,12 @@ from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jb.config import settings
-from jb.models.auth import AuthenticatedUser
+from jb.decorators import gr_api_manager
+from jb.models.auth import User
bearer = HTTPBearer(auto_error=False)
logger = logging.getLogger(__name__)
-AUTH_CACHE_TTL_SECONDS = 60
-
SESSION_COOKIE_NAME = "jb_session"
JWT_ISSUER = "jamesbillings67"
JWT_AUDIENCE = "jamesbillings67"
@@ -23,7 +22,7 @@ JWT_AUDIENCE = "jamesbillings67"
def get_authenticated_user(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
-) -> AuthenticatedUser:
+) -> User:
"""FastAPI dependency for endpoints requiring a valid session."""
if settings.session_jwt_secret is None:
raise HTTPException(
@@ -66,11 +65,9 @@ def get_authenticated_user(
)
product_user_id = claims.get("sub")
- # todo: in here, hit THL by the user's bpuid (email hash),
- # in order to 1) be sure user exists & 2) pull the display_name
- user_email = ... # lookup in thl by product_user_id
+ user = gr_api_manager.get_user(product_user_id=product_user_id)
- return AuthenticatedUser(email=user_email)
+ return user
def create_session(product_user_id: str) -> str:
diff --git a/jb/config.py b/jb/config.py
index c7d07e5..a91494d 100644
--- a/jb/config.py
+++ b/jb/config.py
@@ -1,6 +1,6 @@
import logging
-from generalresearchutils.config import is_debug
+from generalresearch.config import is_debug
from jb.settings import get_settings, get_test_settings
diff --git a/jb/decorators.py b/jb/decorators.py
index 5c1b1f5..cafd7a9 100644
--- a/jb/decorators.py
+++ b/jb/decorators.py
@@ -1,7 +1,7 @@
import boto3
from botocore.config import Config
-from generalresearchutils.pg_helper import PostgresConfig
-from generalresearchutils.redis_helper import RedisConfig
+from generalresearch.pg_helper import PostgresConfig
+from generalresearch.redis_helper import RedisConfig
from influxdb import InfluxDBClient
from mypy_boto3_mturk import MTurkClient
from mypy_boto3_sns import SNSClient
@@ -11,7 +11,8 @@ from jb.managers import Permission
from jb.managers.amt import AMTManager
from jb.managers.assignment import AssignmentManager
from jb.managers.bonus import BonusManager
-from jb.managers.hit import HitTypeManager, HitManager, HitQuestionManager
+from jb.managers.gr_api import GRApiManager
+from jb.managers.hit import HitManager, HitQuestionManager, HitTypeManager
redis_config = RedisConfig(
dsn=settings.redis,
@@ -32,6 +33,12 @@ CLIENT_CONFIG = Config(
read_timeout=2.5,
)
+gr_api_manager = GRApiManager(
+ base_url=str(settings.gr_api_host),
+ token=settings.gr_api_token.get_secret_value(),
+ product_id=settings.product_id,
+)
+
# We shouldn't use this directly. Use our AMTManager wrapper
AMT_CLIENT: MTurkClient = boto3.client(
service_name="mturk",
diff --git a/jb/flow/assignment_tasks.py b/jb/flow/assignment_tasks.py
index 18a844e..2db20f8 100644
--- a/jb/flow/assignment_tasks.py
+++ b/jb/flow/assignment_tasks.py
@@ -3,9 +3,9 @@ import math
from datetime import timedelta
from typing import Optional
-from generalresearchutils.models.thl.definitions import PayoutStatus, StatusCode1
-from generalresearchutils.models.thl.wallet.cashout_method import CashoutRequestInfo
-from generalresearchutils.currency import USDCent
+from generalresearch.models.thl.definitions import PayoutStatus, StatusCode1
+from generalresearch.models.thl.wallet.cashout_method import CashoutRequestInfo
+from generalresearch.currency import USDCent
from jb.flow.monitoring import emit_error_event, emit_assignment_event, emit_bonus_event
from jb.managers.amt import (
diff --git a/jb/flow/monitoring.py b/jb/flow/monitoring.py
index 28f7271..334eab3 100644
--- a/jb/flow/monitoring.py
+++ b/jb/flow/monitoring.py
@@ -5,7 +5,7 @@ from mypy_boto3_mturk.literals import EventTypeType
from jb.config import settings
from jb.decorators import influx_client
-from generalresearchutils.currency import USDCent
+from generalresearch.currency import USDCent
from jb.models.definitions import HitStatus, AssignmentStatus
diff --git a/jb/flow/tasks.py b/jb/flow/tasks.py
index 77825d3..6c5d6bb 100644
--- a/jb/flow/tasks.py
+++ b/jb/flow/tasks.py
@@ -2,7 +2,7 @@ import logging
import time
from typing import TypedDict, cast
-from generalresearchutils.config import is_debug
+from generalresearch.config import is_debug
from jb.decorators import AMTM, HTM, HM, HQM, pg_config
from jb.flow.maintenance import check_hit_status
diff --git a/jb/managers/__init__.py b/jb/managers/__init__.py
index e99569a..ec64f9f 100644
--- a/jb/managers/__init__.py
+++ b/jb/managers/__init__.py
@@ -1,7 +1,7 @@
from enum import IntEnum
from typing import Collection
-from generalresearchutils.pg_helper import PostgresConfig
+from generalresearch.pg_helper import PostgresConfig
class Permission(IntEnum):
diff --git a/jb/managers/amt.py b/jb/managers/amt.py
index 88ae07d..2cb0cbd 100644
--- a/jb/managers/amt.py
+++ b/jb/managers/amt.py
@@ -3,7 +3,7 @@ from datetime import timezone, datetime
from typing import Tuple, Optional, List, Dict, Any
import botocore.exceptions
-from generalresearchutils.currency import USDCent
+from generalresearch.currency import USDCent
from mypy_boto3_mturk import MTurkClient
from mypy_boto3_mturk.type_defs import (
AssignmentTypeDef,
diff --git a/jb/managers/gr_api.py b/jb/managers/gr_api.py
new file mode 100644
index 0000000..8ffeaf0
--- /dev/null
+++ b/jb/managers/gr_api.py
@@ -0,0 +1,112 @@
+"""Client for General Research's product-user API."""
+
+from typing import Any
+
+import requests
+
+from jb.models.auth import User
+
+
+class GRApiError(RuntimeError):
+ """The General Research API could not satisfy a request."""
+
+
+class GRApiManager:
+ def __init__(
+ self,
+ base_url: str,
+ token: str,
+ product_id: str,
+ timeout: float = 5.0,
+ session: requests.Session | None = None,
+ ) -> None:
+ if not token:
+ raise ValueError("General Research API token must not be empty")
+
+ self.base_url = base_url.rstrip("/")
+ self.product_id = product_id
+ self.timeout = timeout
+ self.session = session or requests.Session()
+ self.session.headers.update(
+ {
+ "Authorization": token,
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ }
+ )
+
+ def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
+ try:
+ response = self.session.request(method, url, timeout=self.timeout, **kwargs)
+ response.raise_for_status()
+ return response
+ except requests.RequestException as exc:
+ raise GRApiError(
+ f"General Research API request failed: {method} {url}"
+ ) from exc
+
+ def _parse_user_response(self, res: dict) -> User:
+ return User.model_validate(
+ {
+ "product_user_id": res["product_user_id"],
+ "email": res["metadata"].get("email_address"),
+ "display_name": res["metadata"].get("display_name"),
+ }
+ )
+
+ def ensure_user_exists(self, user: User) -> User:
+ """Idempotently create the product user if it does not already exist."""
+ url = f"{self.base_url}/{self.product_id}/user/{user.product_user_id}/"
+ res = self._request("PUT", url).json()
+ if res["metadata"].get("email_address") is None:
+ self.set_user_email(user)
+ return self.get_user(user.product_user_id)
+ user_thl = self._parse_user_response(res)
+ if user_thl.email != user.email:
+ raise ValueError(
+ f"user {user.product_user_id} already exists with email {user_thl.email}"
+ )
+ return user_thl
+
+ def get_user(self, product_user_id: str) -> User:
+ """Retrieve the user's email address and display name."""
+ url = f"{self.base_url}/{self.product_id}/user/{product_user_id}/"
+ res = self._request("GET", url).json()
+ return self._parse_user_response(res)
+
+ def get_user_by_email(self, email: str) -> User:
+ user = User.model_validate({"email": email})
+ return self.get_user(user.product_user_id)
+
+ def set_user_email(self, user: User) -> None:
+ """This should only be called once per user upon account creation.
+ A user cannot change their email address."""
+ url = f"{self.base_url}/{self.product_id}/user/{user.product_user_id}/metadata/"
+ res = self._request(
+ "PATCH",
+ url,
+ json={"email_address": str(user.email)},
+ ).json()
+
+ def set_user_display_name(self, user: User) -> User:
+ """Can be called as many times as needed. Multiple
+ users can have the same display name."""
+ if user.display_name is None:
+ return user
+ url = f"{self.base_url}/{self.product_id}/user/{user.product_user_id}/metadata/"
+ res = self._request(
+ "PATCH",
+ url,
+ json={"display_name": user.display_name},
+ ).json()
+ return self.get_user(user.product_user_id)
+
+ def transition_product_user_id(self, user: User, amt_worker_id: str) -> User:
+ """This should only be called once upon transition from an
+ AMT account to a General Research account."""
+ url = f"{self.base_url}/{self.product_id}/user/{amt_worker_id}/"
+ res = self._request(
+ "PATCH", url, json={"product_user_id": user.product_user_id}
+ )
+ self.set_user_email(user)
+ return self.get_user(user.product_user_id)
diff --git a/jb/managers/thl.py b/jb/managers/thl.py
index 6e8effc..f0534db 100644
--- a/jb/managers/thl.py
+++ b/jb/managers/thl.py
@@ -1,16 +1,16 @@
-from generalresearchutils.models.thl.payout import UserPayoutEvent
-from generalresearchutils.models.thl.task_status import TaskStatusResponse
-from generalresearchutils.models.thl.wallet.cashout_method import (
+from generalresearch.models.thl.payout import UserPayoutEvent
+from generalresearch.models.thl.task_status import TaskStatusResponse
+from generalresearch.models.thl.wallet.cashout_method import (
CashoutRequestResponse,
CashoutRequestInfo,
)
-from generalresearchutils.models.thl.user_profile import UserProfile
-from generalresearchutils.currency import USDCent
+from generalresearch.models.thl.user_profile import UserProfile
+from generalresearch.currency import USDCent
from jb.config import settings
-from generalresearchutils.models.thl.definitions import PayoutStatus
+from generalresearch.models.thl.definitions import PayoutStatus
from typing import Optional
diff --git a/jb/models/auth.py b/jb/models/auth.py
index 5f31bd3..8b48088 100644
--- a/jb/models/auth.py
+++ b/jb/models/auth.py
@@ -1,5 +1,6 @@
import hashlib
import hmac
+from typing import Any
from pydantic import (
BaseModel,
@@ -8,6 +9,8 @@ from pydantic import (
Field,
TypeAdapter,
computed_field,
+ field_validator,
+ model_validator,
)
from jb.config import settings
@@ -30,29 +33,59 @@ def email_to_product_user_id(email: str) -> str:
return hmac.new(
key=salt_bytes,
msg=normalized_email.encode("utf-8"),
- digestmod=hashlib.sha256,
+ digestmod=hashlib.sha1,
).hexdigest()
-class AuthenticatedUser(BaseModel):
+class User(BaseModel):
"""A user that has been authenticated and exists in THL"""
email: EmailStr = Field()
+ display_name: str | None = Field(default=None, max_length=255)
+
+ @field_validator("email", mode="before")
+ @classmethod
+ def normalize_email(cls, value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ if not value.isascii():
+ raise ValueError("email must contain ASCII characters only")
+ return value.lower()
+
+ @model_validator(mode="before")
+ @classmethod
+ def validate_product_user_id(cls, data: Any) -> Any:
+ if not isinstance(data, dict) or "product_user_id" not in data:
+ return data
+
+ provided_id = data["product_user_id"]
+ email = data.get("email")
+ if email is None:
+ return data
+
+ expected_id = email_to_product_user_id(str(email))
+ if not isinstance(provided_id, str) or not hmac.compare_digest(
+ provided_id, expected_id
+ ):
+ raise ValueError(f"product_user_id {provided_id} does not match email {email}")
+
+ # The computed field is authoritative; do not retain the input value.
+ validated_data = dict(data)
+ validated_data.pop("product_user_id")
+ return validated_data
+
@computed_field
+ @property
def product_user_id(self) -> str:
return email_to_product_user_id(self.email)
-class AccountCreate(BaseModel):
- email: EmailStr = Field()
-
-
class AccountLogin(BaseModel):
+ # There is no practical difference between an Account "login" and "create"
email: EmailStr = Field()
-
class MagicLinkExchangeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
diff --git a/jb/models/bonus.py b/jb/models/bonus.py
index a536dd1..5f81add 100644
--- a/jb/models/bonus.py
+++ b/jb/models/bonus.py
@@ -3,7 +3,7 @@ from typing import Optional, Dict, Any
from pydantic import BaseModel, Field, ConfigDict, PositiveInt
from typing_extensions import Self
-from generalresearchutils.currency import USDCent
+from generalresearch.currency import USDCent
from jb.models.custom_types import AMTBoto3ID, AwareDatetimeISO, UUIDStr
diff --git a/jb/models/hit.py b/jb/models/hit.py
index fba2ecf..45478fc 100644
--- a/jb/models/hit.py
+++ b/jb/models/hit.py
@@ -13,7 +13,7 @@ from pydantic import (
)
from typing_extensions import Self
-from generalresearchutils.currency import USDCent
+from generalresearch.currency import USDCent
from jb.models.custom_types import AMTBoto3ID, HttpsUrlStr, AwareDatetimeISO
from jb.models.definitions import HitStatus, HitReviewStatus
diff --git a/jb/settings.py b/jb/settings.py
index cc1e870..78b4b02 100644
--- a/jb/settings.py
+++ b/jb/settings.py
@@ -3,7 +3,7 @@ from functools import lru_cache
from pathlib import Path
from typing import Optional
-from generalresearchutils.models.custom_types import InfluxDsn
+from generalresearch.models.custom_types import InfluxDsn
from pydantic import Field, PostgresDsn, HttpUrl, RedisDsn, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -60,6 +60,11 @@ class Settings(AmtJbBaseSettings):
magic_token_salt: SecretStr = Field(min_length=32)
+ gr_api_host: HttpUrl = Field(
+ default=HttpUrl("https://generalresearch.com/api/v2/")
+ )
+ gr_api_token: SecretStr = Field(min_length=1)
+
class TestSettings(Settings):
model_config = SettingsConfigDict(
diff --git a/jb/views/auth.py b/jb/views/auth.py
index 0591294..06dbdde 100644
--- a/jb/views/auth.py
+++ b/jb/views/auth.py
@@ -6,8 +6,9 @@ has resolved an email address to its stable user identifier, call
"""
from typing import Annotated
+from urllib.parse import urlencode
-from fastapi import APIRouter, Depends, Response, status
+from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.responses import HTMLResponse
from jb.api.auth import (
@@ -15,11 +16,13 @@ from jb.api.auth import (
create_session,
get_authenticated_user,
)
-from jb.api.magic_token import consume_magic_token
+from jb.api.magic_token import consume_magic_token, create_magic_token
from jb.config import settings
+from jb.decorators import gr_api_manager
from jb.models.auth import (
MagicLinkExchangeRequest,
- AuthenticatedUser,
+ AccountLogin,
+ User,
email_to_product_user_id,
)
from jb.settings import BASE_HTML
@@ -27,6 +30,20 @@ from jb.settings import BASE_HTML
auth_router = APIRouter(prefix="/auth", tags=["Auth"])
+@auth_router.post("/magic-link/request")
+def request_mock_magic_link(body: AccountLogin) -> dict[str, str]:
+ """Create a magic link without sending email in development."""
+ if not settings.debug:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
+
+ # todo: send email here
+
+ user = User(email=body.email)
+ token = create_magic_token(str(user.email))
+ query = urlencode({"token": token})
+ return {"magic_link": f"/auth/magic-link/?{query}"}
+
+
@auth_router.get("/magic-link/", response_class=HTMLResponse, include_in_schema=False)
def magic_link_landing_page() -> HTMLResponse:
"""Serve the SPA without redeeming the token; email prefetches are harmless."""
@@ -44,11 +61,12 @@ def magic_link_landing_page() -> HTMLResponse:
def exchange_magic_link(body: MagicLinkExchangeRequest, response: Response) -> None:
"""Exchange a magic link only after its landing page makes an explicit POST."""
user_email = consume_magic_token(body.token)
- product_user_id = email_to_product_user_id(user_email)
- # todo: hit thl to make sure this user exists
+ user = User.model_validate({'email': user_email})
+ # hit thl to make sure this user exists
+ user = gr_api_manager.ensure_user_exists(user)
- session_token = create_session(product_user_id)
+ session_token = create_session(user.product_user_id)
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=session_token,
@@ -60,10 +78,10 @@ def exchange_magic_link(body: MagicLinkExchangeRequest, response: Response) -> N
)
-@auth_router.get("/session", response_model=AuthenticatedUser)
+@auth_router.get("/session", response_model=User)
def get_session(
- user: Annotated[AuthenticatedUser, Depends(get_authenticated_user)],
-) -> AuthenticatedUser:
+ user: Annotated[User, Depends(get_authenticated_user)],
+) -> User:
return user
diff --git a/requirements.txt b/requirements.txt
index c23b668..315a711 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-git+ssh://code.g-r-l.com:6611/py-utils@v3.3.4
+git+ssh://code.g-r-l.com:6611/generalresearch@v3.4.5
aiohappyeyeballs==2.6.1
aiohttp==3.13.0
aiosignal==1.4.0
@@ -24,6 +24,7 @@ dnspython==2.8.0
email-validator==2.3.0
Faker==37.11.0
fastapi==0.119.0
+filelock==3.32.5
frozenlist==1.8.0
fsspec==2025.9.0
geoip2==5.1.0
@@ -51,22 +52,24 @@ pandas==2.3.3
pandera==0.26.1
partd==1.4.2
pathspec==0.12.1
+phonenumbers==9.0.38
platformdirs==4.5.0
pluggy==1.6.0
propcache==0.4.1
protobuf==6.32.1
psutil==7.1.1
psycopg==3.2.10
+pyarrow==25.0.1
pycountry==24.6.1
pydantic==2.12.0
pydantic-extra-types==2.10.6
pydantic-settings==2.11.0
pydantic_core==2.41.1
Pygments==2.19.2
+PyJWT==2.13.0
pylibmc==1.6.3
pymemcache==4.0.0
PyMySQL==1.1.2
-PyJWT==2.13.0
pytest==8.4.2
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
@@ -75,6 +78,7 @@ pytz==2025.2
PyYAML==6.0.3
redis==6.4.0
requests==2.32.5
+requests-file==3.0.1
requests-mock==1.12.1
s3transfer==0.14.0
scipy==1.16.2
@@ -86,6 +90,7 @@ sniffio==1.3.1
sortedcontainers==2.4.0
starlette==0.48.0
tblib==3.2.0
+tldextract==5.3.2
toolz==1.1.0
tornado==6.5.2
typeguard==4.4.4