T101: web backend proxy (auth, catalog, wallet, governance) + tests

This commit is contained in:
m2 (AI Agent) 2026-07-02 05:39:58 +02:00
parent e896a60bc0
commit e77165ed72
11 changed files with 738 additions and 1 deletions

View file

@ -5,7 +5,7 @@ description = "M2 Marketplace monorepo — schemas, ledger, CLI, indexer (spec 0
requires-python = ">=3.12"
[tool.uv.workspace]
members = ["ledger", "cli", "indexer"]
members = ["ledger", "cli", "indexer", "web/backend"]
[tool.ruff]
line-length = 100

View file

@ -0,0 +1,18 @@
[project]
name = "m2-market-web"
version = "0.1.0"
description = "FastAPI backend proxy for the M2 Market web app"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.111",
"httpx>=0.27",
"itsdangerous>=2.2",
"uvicorn>=0.30",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/m2_market_web"]

View file

@ -0,0 +1 @@
"""M2 Market web backend."""

View file

@ -0,0 +1,65 @@
from __future__ import annotations
import hmac
import os
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from pydantic import BaseModel
COOKIE_NAME = "m2mw_session"
SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
router = APIRouter()
class LoginBody(BaseModel):
passcode: str
def _serializer() -> URLSafeTimedSerializer:
secret = os.environ.get("SESSION_SECRET")
if not secret:
raise HTTPException(status_code=500, detail="session_not_configured")
return URLSafeTimedSerializer(secret, salt="m2-market-web-session")
def _configured_passcode() -> str:
passcode = os.environ.get("FLEET_PASSCODE")
if not passcode:
raise HTTPException(status_code=500, detail="auth_not_configured")
return passcode
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
def login(body: LoginBody, response: Response) -> None:
if not hmac.compare_digest(body.passcode, _configured_passcode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
token = _serializer().dumps({"scope": "fleet"})
response.set_cookie(
COOKIE_NAME,
token,
max_age=SESSION_MAX_AGE_SECONDS,
httponly=True,
secure=True,
samesite="lax",
)
def require_session(
session_cookie: Annotated[str | None, Cookie(alias=COOKIE_NAME)] = None,
) -> None:
if not session_cookie:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
try:
payload = _serializer().loads(session_cookie, max_age=SESSION_MAX_AGE_SECONDS)
except (BadSignature, SignatureExpired):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
) from None
if payload.get("scope") != "fleet":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
SessionDependency = Depends(require_session)

View file

@ -0,0 +1,117 @@
from __future__ import annotations
import os
from typing import Any
import httpx
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from .auth import SessionDependency
from .http import client, upstream_error
PARTITION = os.environ.get("M2_MARKET_CATALOG_PARTITION", "market:catalog")
TENANT_DEFAULT = "m2-core"
router = APIRouter(dependencies=[SessionDependency])
def _tenant() -> str:
return os.environ.get("TENANT", TENANT_DEFAULT)
def _visible(listing: dict[str, Any]) -> bool:
visibility = listing.get("tenant_visibility") or []
return "*" in visibility or _tenant() in visibility
def _price(listing: dict[str, Any]) -> dict[str, Any]:
price = listing.get("price")
if isinstance(price, dict):
return {
"amount": price.get("amount", 0),
"currency": price.get("currency", "credits"),
"model": price.get("model", "fixed"),
}
return {"amount": listing.get("price_amount", 0), "currency": "credits", "model": "fixed"}
def _listing_dto(listing_id: str, listing: dict[str, Any], score: Any = None) -> dict[str, Any]:
return {
"listing_id": listing_id,
"name": listing.get("name") or listing_id,
"summary": listing.get("summary") or "",
"category": listing.get("category") or "",
"price": _price(listing),
"seller": listing.get("seller") or listing.get("seller_id") or "",
"stats": listing.get("stats") or {},
"score": score,
}
def _detail_dto(listing_id: str, listing: dict[str, Any], score: Any = None) -> dict[str, Any]:
dto = _listing_dto(listing_id, listing, score)
dto.update(
{
"evidence_summary": listing.get("evidence_summary") or "",
"permissions": listing.get("permissions") or [],
"install_ref": listing.get("install_ref") or "",
"install_command": f"m2-market install {listing_id} --yes",
}
)
return dto
async def _memory_search(query_text: str, limit: int) -> list[dict[str, Any]]:
base_url = os.environ["MEMORY_API_URL"]
api_key = os.environ["MEMORY_API_KEY"]
async with client(base_url, {"X-API-Key": api_key}) as http:
response = await http.post(
"/memory/search",
json={
"query": query_text,
"agent_id": PARTITION,
"memory_types": ["semantic"],
"limit": max(limit * 3, limit),
},
)
response.raise_for_status()
items: list[dict[str, Any]] = []
for result in response.json().get("results", []):
meta = result.get("metadata") or {}
listing = meta.get("listing")
listing_id = meta.get("listing_id")
if not isinstance(listing, dict) or not listing_id:
continue
if not _visible(listing):
continue
items.append(
{"listing_id": listing_id, "score": result.get("score"), "listing": listing}
)
if len(items) >= limit:
break
return items
@router.get("/search", response_model=None)
async def search(
q: str = "", limit: int = Query(10, ge=1, le=50)
) -> list[dict[str, Any]] | JSONResponse:
try:
return [
_listing_dto(i["listing_id"], i["listing"], i["score"])
for i in await _memory_search(q, limit)
]
except (httpx.HTTPError, KeyError):
return upstream_error("catalog")
@router.get("/listing/{listing_id}", response_model=None)
async def listing(listing_id: str) -> dict[str, Any] | JSONResponse:
try:
for item in await _memory_search(listing_id, 10):
if item["listing_id"] == listing_id:
return _detail_dto(item["listing_id"], item["listing"], item["score"])
return upstream_error("catalog")
except (httpx.HTTPError, KeyError):
return upstream_error("catalog")

View file

@ -0,0 +1,21 @@
from __future__ import annotations
import httpx
from fastapi.responses import JSONResponse
def upstream_error(panel: str) -> JSONResponse:
return JSONResponse(status_code=502, content={"error": "upstream_failure", "panel": panel})
def async_transport() -> httpx.AsyncBaseTransport | None:
return None
def client(base_url: str, headers: dict[str, str] | None = None) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=base_url.rstrip("/"),
headers=headers or {},
timeout=20.0,
transport=async_transport(),
)

View file

@ -0,0 +1,104 @@
from __future__ import annotations
import os
from datetime import date
from typing import Any
import httpx
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from .auth import SessionDependency
from .http import client, upstream_error
router = APIRouter(dependencies=[SessionDependency])
def _headers() -> dict[str, str]:
return {"X-API-Key": os.environ["LEDGER_SERVICE_KEY"]}
def _tx_dto(tx: dict[str, Any]) -> dict[str, Any]:
return {
"ts": tx.get("ts"),
"from": tx.get("from"),
"to": tx.get("to"),
"amount": tx.get("amount"),
"reason": tx.get("reason"),
"ref": tx.get("ref"),
}
def _registry_raw_url(path: str) -> str:
forgejo = os.environ["FORGEJO_URL"].rstrip("/")
repo = os.environ["REGISTRY_REPO"].strip("/")
return f"{forgejo}/{repo}/raw/branch/main/{path}"
async def _latest_audit_date() -> str:
today = date.today().isoformat()
forgejo = os.environ["FORGEJO_URL"].rstrip("/")
repo = os.environ["REGISTRY_REPO"].strip("/")
token = os.environ.get("FORGEJO_TOKEN", "")
headers = {"Authorization": f"token {token}"} if token else {}
async with client(forgejo, headers) as http:
response = await http.get(f"/api/v1/repos/{repo}/contents/audit", params={"ref": "main"})
response.raise_for_status()
dates = sorted(
item["name"].removesuffix(".json")
for item in response.json()
if isinstance(item, dict) and str(item.get("name", "")).endswith(".json")
)
return today if today in dates else (dates[-1] if dates else today)
@router.get("/wallet/{operator_id}", response_model=None)
async def wallet(operator_id: str) -> dict[str, Any] | JSONResponse:
try:
async with client(os.environ["LEDGER_URL"], _headers()) as http:
balance = await http.get(f"/balance/{operator_id}")
balance.raise_for_status()
tx_log = await http.get("/tx", params={"operator_id": operator_id})
tx_log.raise_for_status()
balance_body = balance.json()
transactions = tx_log.json().get("transactions", [])[:20]
return {
"operator_id": operator_id,
"balance": balance_body.get("balance", 0),
"as_of": balance_body.get("as_of"),
"transactions": [_tx_dto(tx) for tx in transactions],
}
except (httpx.HTTPError, KeyError):
return upstream_error("wallet")
@router.get("/economy", response_model=None)
async def economy() -> dict[str, Any] | JSONResponse:
try:
async with client(os.environ["LEDGER_URL"], _headers()) as http:
tx_log = await http.get("/tx")
tx_log.raise_for_status()
transactions = tx_log.json().get("transactions", [])
operators = sorted(
{
party
for tx in transactions
for party in (tx.get("from"), tx.get("to"))
if party and party != "mint"
}
)
balances = []
for operator_id in operators:
response = await http.get(f"/balance/{operator_id}")
response.raise_for_status()
body = response.json()
balances.append(
{"operator_id": operator_id, "balance": body.get("balance", 0)}
)
audit_date = await _latest_audit_date()
return {
"operators": balances,
"audit": {"date": audit_date, "url": _registry_raw_url(f"audit/{audit_date}.json")},
}
except (httpx.HTTPError, KeyError):
return upstream_error("economy")

View file

@ -0,0 +1,41 @@
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from . import auth, catalog, ledger, registry
def create_app() -> FastAPI:
app = FastAPI(title="m2-market-web")
app.include_router(auth.router, prefix="/api")
app.include_router(catalog.router, prefix="/api")
app.include_router(ledger.router, prefix="/api")
app.include_router(registry.router, prefix="/api")
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "healthy"}
dist = Path(__file__).resolve().parents[3] / "frontend" / "dist"
@app.get("/{path:path}", include_in_schema=False)
def static_or_spa(path: str, request: Request):
if request.url.path.startswith("/api/"):
return JSONResponse(status_code=404, content={"error": "not_found"})
if not dist.exists():
return JSONResponse(status_code=404, content={"error": "frontend_not_built"})
requested = (dist / path).resolve()
if requested.is_file() and dist.resolve() in requested.parents:
return FileResponse(requested)
index = dist / "index.html"
if index.is_file():
return FileResponse(index)
return JSONResponse(status_code=404, content={"error": "frontend_not_built"})
return app
app = create_app()

View file

@ -0,0 +1,97 @@
from __future__ import annotations
import base64
import json
import os
from datetime import UTC, datetime
from typing import Any
import httpx
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from .auth import SessionDependency
from .http import client, upstream_error
router = APIRouter(dependencies=[SessionDependency])
def _repo() -> str:
return os.environ["REGISTRY_REPO"].strip("/")
def _headers() -> dict[str, str]:
token = os.environ["FORGEJO_TOKEN"]
return {"Authorization": f"token {token}"}
def _age_days(created_at: str | None) -> int:
if not created_at:
return 0
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
return max(0, (datetime.now(UTC) - created).days)
def _listing_url(listing_id: str) -> str:
return (
f"{os.environ['FORGEJO_URL'].rstrip('/')}/{_repo()}"
f"/src/branch/main/listings/{listing_id}"
)
async def _listing_status(http: httpx.AsyncClient, listing_id: str) -> str:
response = await http.get(
f"/api/v1/repos/{_repo()}/contents/listings/{listing_id}/listing.json",
params={"ref": "main"},
)
response.raise_for_status()
body = response.json()
if not isinstance(body, dict):
return "published"
listing = body if "status" in body else body.get("content_json", {})
if not listing and isinstance(body, dict) and body.get("content"):
content = str(body["content"]).replace("\n", "")
listing = json.loads(base64.b64decode(content).decode("utf-8"))
return listing.get("status", "published") if isinstance(listing, dict) else "published"
@router.get("/governance", response_model=None)
async def governance() -> dict[str, Any] | JSONResponse:
try:
async with client(os.environ["FORGEJO_URL"], _headers()) as http:
prs_response = await http.get(
f"/api/v1/repos/{_repo()}/pulls", params={"state": "open"}
)
prs_response.raise_for_status()
open_prs = [
{
"number": pr.get("number"),
"title": pr.get("title", ""),
"age_days": _age_days(pr.get("created_at")),
"labels": [label.get("name", "") for label in pr.get("labels", [])],
"url": pr.get("html_url") or pr.get("url"),
}
for pr in prs_response.json()
]
listings_response = await http.get(
f"/api/v1/repos/{_repo()}/contents/listings", params={"ref": "main"}
)
listings_response.raise_for_status()
listings = []
for item in listings_response.json():
if not isinstance(item, dict) or item.get("type") not in {None, "dir"}:
continue
listing_id = item.get("name")
if not listing_id:
continue
listings.append(
{
"listing_id": listing_id,
"status": await _listing_status(http, listing_id),
"url": _listing_url(listing_id),
}
)
return {"open_prs": open_prs, "listings": listings}
except (httpx.HTTPError, KeyError, ValueError):
return upstream_error("governance")

View file

@ -0,0 +1,6 @@
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

View file

@ -0,0 +1,267 @@
from __future__ import annotations
import json
from collections.abc import Callable
import httpx
import pytest
from fastapi.testclient import TestClient
from m2_market_web import http as web_http
from m2_market_web.main import create_app
SECRET_VALUES = {
"fleet-passcode-secret",
"session-secret-value",
"mem-secret-key",
"ledger-secret-key",
"forgejo-secret-token",
}
@pytest.fixture(autouse=True)
def env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FLEET_PASSCODE", "fleet-passcode-secret")
monkeypatch.setenv("SESSION_SECRET", "session-secret-value")
monkeypatch.setenv("MEMORY_API_URL", "https://memory.example")
monkeypatch.setenv("MEMORY_API_KEY", "mem-secret-key")
monkeypatch.setenv("LEDGER_URL", "https://ledger.example")
monkeypatch.setenv("LEDGER_SERVICE_KEY", "ledger-secret-key")
monkeypatch.setenv("FORGEJO_URL", "https://git.example")
monkeypatch.setenv("FORGEJO_TOKEN", "forgejo-secret-token")
monkeypatch.setenv("REGISTRY_REPO", "m2/market-registry")
monkeypatch.setenv("TENANT", "m2-core")
@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch) -> Callable[[httpx.MockTransport], TestClient]:
def build(transport: httpx.MockTransport) -> TestClient:
monkeypatch.setattr(web_http, "async_transport", lambda: transport)
return TestClient(create_app(), base_url="https://testserver")
return build
def _authed(test_client: TestClient) -> None:
response = test_client.post("/api/login", json={"passcode": "fleet-passcode-secret"})
assert response.status_code == 204
def _listing() -> dict:
return {
"name": "PDF Export",
"summary": "Branded reports",
"category": "reporting",
"price": {"amount": 7, "currency": "credits", "model": "fixed"},
"seller": "m2bd",
"stats": {"installs": 3},
"tenant_visibility": ["*"],
"evidence_summary": "Used in one production workflow",
"permissions": ["filesystem:write"],
"install_ref": "lst_pdf-v1.0.0",
}
def _ok_transport(request: httpx.Request) -> httpx.Response:
if request.url.host == "memory.example":
assert request.headers["X-API-Key"] == "mem-secret-key"
body = json.loads(request.content)
assert body["agent_id"] == "market:catalog"
assert body["limit"] == 6 or body["limit"] == 30
return httpx.Response(
200,
json={
"results": [
{
"score": 0.9,
"metadata": {"listing_id": "lst_pdf", "listing": _listing()},
},
{
"score": 0.8,
"metadata": {
"listing_id": "lst_hidden",
"listing": {"name": "Hidden", "tenant_visibility": ["other"]},
},
},
]
},
)
if request.url.host == "ledger.example":
assert request.headers["X-API-Key"] == "ledger-secret-key"
if request.url.path == "/balance/m2bd":
return httpx.Response(
200,
json={"operator_id": "m2bd", "balance": 42, "as_of": "2026-07-02T10:00:00Z"},
)
if request.url.path == "/balance/platform":
return httpx.Response(200, json={"operator_id": "platform", "balance": 8})
if request.url.path == "/tx" and request.url.params.get("operator_id") == "m2bd":
return httpx.Response(
200,
json={
"transactions": [
{
"ts": "2026-07-02T09:00:00Z",
"from": "mint",
"to": "m2bd",
"amount": 50,
"reason": "grant",
"ref": "starter",
"private": "ignored",
}
]
},
)
if request.url.path == "/tx":
return httpx.Response(
200,
json={
"transactions": [
{"from": "mint", "to": "m2bd", "amount": 50},
{"from": "m2bd", "to": "platform", "amount": 8},
]
},
)
if request.url.host == "git.example":
assert request.headers["Authorization"] == "token forgejo-secret-token"
if request.url.path == "/api/v1/repos/m2/market-registry/contents/audit":
return httpx.Response(200, json=[{"name": "2026-07-01.json"}])
if request.url.path == "/api/v1/repos/m2/market-registry/pulls":
return httpx.Response(
200,
json=[
{
"number": 12,
"title": "Publish PDF Export",
"created_at": "2026-07-01T10:00:00Z",
"labels": [{"name": "approved"}],
"html_url": "https://git.example/m2/market-registry/pulls/12",
}
],
)
if request.url.path == "/api/v1/repos/m2/market-registry/contents/listings":
return httpx.Response(200, json=[{"name": "lst_pdf", "type": "dir"}])
if (
request.url.path
== "/api/v1/repos/m2/market-registry/contents/listings/lst_pdf/listing.json"
):
return httpx.Response(200, json={"content_json": {"status": "published"}})
return httpx.Response(404, json={"unexpected": str(request.url)})
def test_login_and_auth_gate(client: Callable[[httpx.MockTransport], TestClient]) -> None:
test_client = client(httpx.MockTransport(_ok_transport))
assert test_client.get("/api/search?q=pdf").status_code == 401
assert test_client.post("/api/login", json={"passcode": "wrong"}).status_code == 401
_authed(test_client)
assert test_client.get("/api/search?q=pdf&limit=2").status_code == 200
def test_catalog_wallet_economy_governance_happy_paths(
client: Callable[[httpx.MockTransport], TestClient],
) -> None:
test_client = client(httpx.MockTransport(_ok_transport))
_authed(test_client)
search = test_client.get("/api/search?q=pdf&limit=2")
assert search.status_code == 200
assert search.json() == [
{
"listing_id": "lst_pdf",
"name": "PDF Export",
"summary": "Branded reports",
"category": "reporting",
"price": {"amount": 7, "currency": "credits", "model": "fixed"},
"seller": "m2bd",
"stats": {"installs": 3},
"score": 0.9,
}
]
listing = test_client.get("/api/listing/lst_pdf")
assert listing.status_code == 200
assert listing.json()["install_command"] == "m2-market install lst_pdf --yes"
assert listing.json()["permissions"] == ["filesystem:write"]
wallet = test_client.get("/api/wallet/m2bd")
assert wallet.status_code == 200
assert wallet.json()["balance"] == 42
assert wallet.json()["transactions"][0] == {
"ts": "2026-07-02T09:00:00Z",
"from": "mint",
"to": "m2bd",
"amount": 50,
"reason": "grant",
"ref": "starter",
}
economy = test_client.get("/api/economy")
assert economy.status_code == 200
assert economy.json()["operators"] == [
{"operator_id": "m2bd", "balance": 42},
{"operator_id": "platform", "balance": 8},
]
assert economy.json()["audit"] == {
"date": "2026-07-01",
"url": "https://git.example/m2/market-registry/raw/branch/main/audit/2026-07-01.json",
}
governance = test_client.get("/api/governance")
assert governance.status_code == 200
assert governance.json()["open_prs"][0]["labels"] == ["approved"]
assert governance.json()["listings"] == [
{
"listing_id": "lst_pdf",
"status": "published",
"url": "https://git.example/m2/market-registry/src/branch/main/listings/lst_pdf",
}
]
@pytest.mark.parametrize(
("path", "failed_host", "panel"),
[
("/api/search?q=pdf", "memory.example", "catalog"),
("/api/wallet/m2bd", "ledger.example", "wallet"),
("/api/economy", "ledger.example", "economy"),
("/api/governance", "git.example", "governance"),
],
)
def test_upstream_failures_are_panel_502(
client: Callable[[httpx.MockTransport], TestClient],
path: str,
failed_host: str,
panel: str,
) -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.host == failed_host:
return httpx.Response(503, json={"secret": "upstream body ignored"})
return _ok_transport(request)
test_client = client(httpx.MockTransport(handler))
_authed(test_client)
response = test_client.get(path)
assert response.status_code == 502
assert response.json() == {"error": "upstream_failure", "panel": panel}
def test_secret_values_never_appear_in_response_bodies(
client: Callable[[httpx.MockTransport], TestClient],
) -> None:
test_client = client(httpx.MockTransport(_ok_transport))
responses = [
test_client.post("/api/login", json={"passcode": "fleet-passcode-secret"}),
]
responses.extend(
[
test_client.get("/health"),
test_client.get("/api/search?q=pdf"),
test_client.get("/api/listing/lst_pdf"),
test_client.get("/api/wallet/m2bd"),
test_client.get("/api/economy"),
test_client.get("/api/governance"),
]
)
for response in responses:
body = response.text
for secret in SECRET_VALUES:
assert secret not in body