Decouple cart/market DBs: denormalize product data, AP internal inbox, OAuth scraper auth

Remove cross-DB relationships (CartItem.product, CartItem.market_place,
OrderItem.product) that break with per-service databases. Denormalize
product and marketplace fields onto cart_items/order_items at write time.

- Add AP internal inbox infrastructure (shared/infrastructure/internal_inbox*)
  for synchronous inter-service writes via HMAC-authenticated POST
- Cart inbox blueprint handles Add/Remove/Update rose:CartItem activities
- Market app sends AP activities to cart inbox instead of writing CartItem directly
- Cart services use denormalized columns instead of cross-DB hydration/joins
- Add marketplaces-by-ids data endpoint to market service
- Alembic migration adds denormalized columns to cart_items and order_items
- Add OAuth device flow auth to market scraper persist_api (artdag client pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 14:49:04 +00:00
parent cf7fbd8e9b
commit 81112c716b
28 changed files with 739 additions and 186 deletions

148
market/scrape/auth.py Normal file
View File

@@ -0,0 +1,148 @@
"""OAuth device flow authentication for the market scraper.
Same flow as the artdag CLI client:
1. Request device code from account server
2. User approves in browser
3. Poll for access token
4. Save token to ~/.artdag/token.json (shared with artdag CLI)
"""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
import httpx
TOKEN_DIR = Path.home() / ".artdag"
TOKEN_FILE = TOKEN_DIR / "token.json"
_DEFAULT_ACCOUNT_SERVER = "https://account.rose-ash.com"
def _get_account_server() -> str:
return os.getenv("ARTDAG_ACCOUNT", _DEFAULT_ACCOUNT_SERVER)
def load_token() -> dict:
"""Load saved token from ~/.artdag/token.json."""
if TOKEN_FILE.exists():
try:
with open(TOKEN_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
return {}
def save_token(token_data: dict):
"""Save token to ~/.artdag/token.json (mode 0600)."""
TOKEN_DIR.mkdir(parents=True, exist_ok=True)
with open(TOKEN_FILE, "w") as f:
json.dump(token_data, f, indent=2)
TOKEN_FILE.chmod(0o600)
def get_access_token(require: bool = True) -> str | None:
"""Return the saved access token, or None.
When *require* is True and no token is found, triggers interactive
device-flow login (same as ``artdag login``).
"""
token = load_token().get("access_token")
if not token and require:
print("No saved token found — starting device-flow login...")
token_data = login()
token = token_data.get("access_token")
return token
def auth_headers() -> dict[str, str]:
"""Return Authorization header dict for HTTP requests."""
token = get_access_token(require=True)
return {"Authorization": f"Bearer {token}"}
def login() -> dict:
"""Interactive device-flow login (blocking, prints to stdout).
Returns the token data dict on success. Exits on failure.
"""
account = _get_account_server()
# 1. Request device code
try:
with httpx.Client(timeout=10) as client:
resp = client.post(
f"{account}/auth/device/authorize",
json={"client_id": "artdag"},
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as e:
print(f"Login failed: {e}", file=sys.stderr)
sys.exit(1)
device_code = data["device_code"]
user_code = data["user_code"]
verification_uri = data["verification_uri"]
expires_in = data.get("expires_in", 900)
interval = data.get("interval", 5)
print("To sign in, open this URL in your browser:")
print(f" {verification_uri}")
print(f" and enter code: {user_code}")
print()
# Try to open browser
try:
import webbrowser
webbrowser.open(verification_uri)
except Exception:
pass
# 2. Poll for approval
print("Waiting for authorization", end="", flush=True)
deadline = time.time() + expires_in
with httpx.Client(timeout=10) as client:
while time.time() < deadline:
time.sleep(interval)
print(".", end="", flush=True)
try:
resp = client.post(
f"{account}/auth/device/token",
json={"device_code": device_code, "client_id": "artdag"},
)
data = resp.json()
except httpx.HTTPError:
continue
error = data.get("error")
if error == "authorization_pending":
continue
elif error == "expired_token":
print("\nCode expired. Please try again.", file=sys.stderr)
sys.exit(1)
elif error == "access_denied":
print("\nAuthorization denied.", file=sys.stderr)
sys.exit(1)
elif error:
print(f"\nLogin failed: {error}", file=sys.stderr)
sys.exit(1)
# Success
token_data = {
"access_token": data["access_token"],
"username": data.get("username", ""),
"display_name": data.get("display_name", ""),
}
save_token(token_data)
print(f"\nLogged in as {token_data['username'] or token_data['display_name']}")
return token_data
print("\nTimed out waiting for authorization.", file=sys.stderr)
sys.exit(1)

View File

@@ -1,16 +1,16 @@
# replace your existing upsert_product with this version
import os
import httpx
from typing import List
from ..auth import auth_headers
async def capture_listing(
url: str,
items: List[str],
total_pages: int
):
sync_url = os.getenv("CAPTURE_LISTING_URL", "http://localhost:8001/market/suma-market/api/products/listing/")
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=10.0)) as client:
@@ -19,7 +19,7 @@ async def capture_listing(
"items": items,
"total_pages": total_pages
}
resp = await client.post(sync_url, json=_d)
resp = await client.post(sync_url, json=_d, headers=auth_headers())
# Raise for non-2xx
resp.raise_for_status()
data = resp.json() if resp.content else {}

View File

@@ -1,14 +1,14 @@
# replace your existing upsert_product with this version
import os
import httpx
from ..auth import auth_headers
async def log_product_result(
ok: bool,
payload
):
sync_url = os.getenv("PRODUCT_LOG_URL", "http://localhost:8000/market/api/products/log/")
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=10.0)) as client:
@@ -16,7 +16,7 @@ async def log_product_result(
"ok": ok,
"payload": payload
}
resp = await client.post(sync_url, json=_d)
resp = await client.post(sync_url, json=_d, headers=auth_headers())
# Raise for non-2xx
resp.raise_for_status()
data = resp.json() if resp.content else {}

View File

@@ -1,17 +1,17 @@
# replace your existing upsert_product with this version
import os
import httpx
from typing import Dict
from ..auth import auth_headers
async def save_nav(
nav: Dict,
):
sync_url = os.getenv("SAVE_NAV_URL", "http://localhost:8001/market/suma-market/api/products/nav/")
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=10.0)) as client:
resp = await client.post(sync_url, json=nav)
resp = await client.post(sync_url, json=nav, headers=auth_headers())
# Raise for non-2xx
resp.raise_for_status()
data = resp.json() if resp.content else {}

View File

@@ -3,11 +3,13 @@ import httpx
from typing import Dict
from ..auth import auth_headers
async def save_subcategory_redirects(mapping: Dict[str, str]) -> None:
sync_url = os.getenv("SAVE_REDIRECTS", "http://localhost:8000/market/api/products/redirects/")
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=10.0)) as client:
resp = await client.post(sync_url, json=mapping)
resp = await client.post(sync_url, json=mapping, headers=auth_headers())
# Raise for non-2xx
resp.raise_for_status()
data = resp.json() if resp.content else {}

View File

@@ -5,6 +5,8 @@ import httpx
from typing import Dict, List, Any
from ..auth import auth_headers
async def upsert_product(
slug,
href,
@@ -30,7 +32,7 @@ async def upsert_product(
async def _do_call() -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=10.0)) as client:
resp = await client.post(sync_url, json=payload)
resp = await client.post(sync_url, json=payload, headers=auth_headers())
resp.raise_for_status()
# tolerate empty body
if not resp.content: