Compare commits
No commits in common. "dcb97f180f755f1c3ce982e7378d88b4a778c838" and "5dd73eb4cd70ed47bae94ea3e99691e34777dab0" have entirely different histories.
dcb97f180f
...
5dd73eb4cd
7 changed files with 37 additions and 536 deletions
|
|
@ -1,13 +1,11 @@
|
||||||
# Contract: m2-market-web /api (v1)
|
# Contract: m2-market-web /api (v1)
|
||||||
|
|
||||||
Config: GET /api/config → {auth_mode:"passcode"|"gpt"|"both"}.
|
Session: POST /api/login {passcode} → 204 + Set-Cookie m2mw_session (signed, 7d) | 401.
|
||||||
Session: POST /api/login {passcode} OR {email,password} → 204 + Set-Cookie m2mw_session (signed, 7d) | 401.
|
|
||||||
Session identity: GET /api/session → {email,role,tenant_id,fleet_admin,operator_id,auth_method} with no upstream token.
|
|
||||||
All /api/* below require the cookie (401 otherwise). /health is open → {"status":"healthy"}.
|
All /api/* below require the cookie (401 otherwise). /health is open → {"status":"healthy"}.
|
||||||
|
|
||||||
- GET /api/search?q=&limit=10 → [{listing_id, name, summary, category, price:{amount,currency,model}, seller, stats, score}]
|
- GET /api/search?q=&limit=10 → [{listing_id, name, summary, category, price:{amount,currency,model}, seller, stats, score}]
|
||||||
- GET /api/listing/{listing_id} → {listing..., evidence_summary, permissions[], install_ref, install_command: "m2-market install <id> --yes"}
|
- GET /api/listing/{listing_id} → {listing..., evidence_summary, permissions[], install_ref, install_command: "m2-market install <id> --yes"}
|
||||||
- GET /api/wallet/{operator_id} → {operator_id, balance, as_of, transactions:[last 20 {ts,from,to,amount,reason,ref}]} | 403 for non-admin foreign wallet
|
- GET /api/wallet/{operator_id} → {operator_id, balance, as_of, transactions:[last 20 {ts,from,to,amount,reason,ref}]}
|
||||||
- GET /api/economy → {operators:[{operator_id,balance}], audit:{date, url}} # url deep-links registry audit file
|
- GET /api/economy → {operators:[{operator_id,balance}], audit:{date, url}} # url deep-links registry audit file
|
||||||
- GET /api/governance → {open_prs:[{number,title,age_days,labels,url}], listings:[{listing_id,status,url}]}
|
- GET /api/governance → {open_prs:[{number,title,age_days,labels,url}], listings:[{listing_id,status,url}]}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,21 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import time
|
from typing import Annotated
|
||||||
from typing import Annotated, Any, Literal
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .http import client
|
|
||||||
|
|
||||||
COOKIE_NAME = "m2mw_session"
|
COOKIE_NAME = "m2mw_session"
|
||||||
SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
||||||
INTROSPECTION_TTL_SECONDS = 10 * 60
|
|
||||||
AUTH_MODES = {"passcode", "gpt", "both"}
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
_introspection_cache: dict[str, float] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class LoginBody(BaseModel):
|
class LoginBody(BaseModel):
|
||||||
passcode: str | None = None
|
passcode: str
|
||||||
email: str | None = None
|
|
||||||
password: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SessionIdentity(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="ignore")
|
|
||||||
|
|
||||||
email: str | None = None
|
|
||||||
role: str
|
|
||||||
tenant_id: str | None = None
|
|
||||||
fleet_admin: bool = False
|
|
||||||
operator_id: str
|
|
||||||
auth_method: Literal["passcode", "gpt"]
|
|
||||||
m2gpt_token: str | None = None
|
|
||||||
|
|
||||||
def public(self) -> dict[str, Any]:
|
|
||||||
return self.model_dump(exclude={"m2gpt_token"})
|
|
||||||
|
|
||||||
|
|
||||||
def _serializer() -> URLSafeTimedSerializer:
|
def _serializer() -> URLSafeTimedSerializer:
|
||||||
|
|
@ -51,11 +25,6 @@ def _serializer() -> URLSafeTimedSerializer:
|
||||||
return URLSafeTimedSerializer(secret, salt="m2-market-web-session")
|
return URLSafeTimedSerializer(secret, salt="m2-market-web-session")
|
||||||
|
|
||||||
|
|
||||||
def _auth_mode() -> str:
|
|
||||||
mode = os.environ.get("AUTH_MODE", "both").lower()
|
|
||||||
return mode if mode in AUTH_MODES else "both"
|
|
||||||
|
|
||||||
|
|
||||||
def _configured_passcode() -> str:
|
def _configured_passcode() -> str:
|
||||||
passcode = os.environ.get("FLEET_PASSCODE")
|
passcode = os.environ.get("FLEET_PASSCODE")
|
||||||
if not passcode:
|
if not passcode:
|
||||||
|
|
@ -63,81 +32,11 @@ def _configured_passcode() -> str:
|
||||||
return passcode
|
return passcode
|
||||||
|
|
||||||
|
|
||||||
def _operator_id(email: str | None, tenant_id: str | None) -> str:
|
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
if tenant_id:
|
def login(body: LoginBody, response: Response) -> None:
|
||||||
return tenant_id
|
if not hmac.compare_digest(body.passcode, _configured_passcode()):
|
||||||
if email and "@" in email:
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
||||||
return email.split("@", 1)[0]
|
token = _serializer().dumps({"scope": "fleet"})
|
||||||
if email:
|
|
||||||
return email
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _is_fleet_admin(role: str | None, fleet_admin: Any) -> bool:
|
|
||||||
if isinstance(fleet_admin, bool):
|
|
||||||
return fleet_admin
|
|
||||||
return str(role or "").lower() in {"fleet_admin", "admin", "owner", "superadmin"}
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_jwt_payload(token: str) -> dict[str, Any]:
|
|
||||||
parts = token.split(".")
|
|
||||||
if len(parts) < 2:
|
|
||||||
return {}
|
|
||||||
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
||||||
try:
|
|
||||||
# This decode is intentionally unverified: the token arrived from m2-gpt over
|
|
||||||
# the direct TLS login exchange, and we only need identity claims for binding.
|
|
||||||
decoded = base64.urlsafe_b64decode(payload.encode()).decode("utf-8")
|
|
||||||
body = json.loads(decoded)
|
|
||||||
return body if isinstance(body, dict) else {}
|
|
||||||
except (ValueError, json.JSONDecodeError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _token_from_response(body: dict[str, Any]) -> str | None:
|
|
||||||
for key in ("token", "access_token", "jwt", "id_token"):
|
|
||||||
token = body.get(key)
|
|
||||||
if isinstance(token, str) and token:
|
|
||||||
return token
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _identity_from_gpt_response(body: dict[str, Any], login_email: str) -> SessionIdentity:
|
|
||||||
token = _token_from_response(body)
|
|
||||||
claims = _decode_jwt_payload(token) if token else {}
|
|
||||||
user = body.get("user") if isinstance(body.get("user"), dict) else {}
|
|
||||||
source = {**claims, **user, **body}
|
|
||||||
|
|
||||||
email = str(source.get("email") or source.get("sub") or login_email)
|
|
||||||
role = str(source.get("role") or source.get("admin_role") or "operator")
|
|
||||||
tenant_id = source.get("tenant_id") or source.get("tenant")
|
|
||||||
tenant = str(tenant_id) if tenant_id else None
|
|
||||||
fleet_admin = _is_fleet_admin(role, source.get("fleet_admin"))
|
|
||||||
|
|
||||||
return SessionIdentity(
|
|
||||||
email=email,
|
|
||||||
role=role,
|
|
||||||
tenant_id=tenant,
|
|
||||||
fleet_admin=fleet_admin,
|
|
||||||
operator_id=_operator_id(email, tenant),
|
|
||||||
auth_method="gpt",
|
|
||||||
m2gpt_token=token if os.environ.get("M2GPT_INTROSPECTION_KEY") else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _passcode_identity() -> SessionIdentity:
|
|
||||||
return SessionIdentity(
|
|
||||||
email=None,
|
|
||||||
role="fleet_admin",
|
|
||||||
tenant_id=None,
|
|
||||||
fleet_admin=True,
|
|
||||||
operator_id="fleet",
|
|
||||||
auth_method="passcode",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_session(response: Response, identity: SessionIdentity) -> None:
|
|
||||||
token = _serializer().dumps(identity.model_dump())
|
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
COOKIE_NAME,
|
COOKIE_NAME,
|
||||||
token,
|
token,
|
||||||
|
|
@ -148,87 +47,9 @@ def _set_session(response: Response, identity: SessionIdentity) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _gpt_login(email: str, password: str) -> SessionIdentity:
|
def require_session(
|
||||||
base_url = os.environ.get("M2GPT_URL")
|
|
||||||
if not base_url:
|
|
||||||
raise HTTPException(status_code=500, detail="gpt_auth_not_configured")
|
|
||||||
try:
|
|
||||||
async with client(base_url) as http:
|
|
||||||
response = await http.post(
|
|
||||||
"/admin/v1/auth/login", json={"email": email, "password": password}
|
|
||||||
)
|
|
||||||
except httpx.HTTPError:
|
|
||||||
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
|
|
||||||
if response.status_code in {401, 403}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
try:
|
|
||||||
response.raise_for_status()
|
|
||||||
body = response.json()
|
|
||||||
except (httpx.HTTPError, ValueError):
|
|
||||||
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
raise HTTPException(status_code=502, detail="gpt_auth_unavailable")
|
|
||||||
return _identity_from_gpt_response(body, email)
|
|
||||||
|
|
||||||
|
|
||||||
async def _revalidate_with_m2gpt(identity: SessionIdentity) -> None:
|
|
||||||
introspection_key = os.environ.get("M2GPT_INTROSPECTION_KEY")
|
|
||||||
if not introspection_key or identity.auth_method != "gpt" or not identity.m2gpt_token:
|
|
||||||
return
|
|
||||||
now = time.time()
|
|
||||||
cached_at = _introspection_cache.get(identity.m2gpt_token)
|
|
||||||
if cached_at and now - cached_at < INTROSPECTION_TTL_SECONDS:
|
|
||||||
return
|
|
||||||
base_url = os.environ.get("M2GPT_URL")
|
|
||||||
if not base_url:
|
|
||||||
raise HTTPException(status_code=500, detail="gpt_auth_not_configured")
|
|
||||||
try:
|
|
||||||
async with client(base_url, {"X-Introspection-Key": introspection_key}) as http:
|
|
||||||
response = await http.post(
|
|
||||||
"/admin/v1/auth/introspect", json={"token": identity.m2gpt_token}
|
|
||||||
)
|
|
||||||
except httpx.HTTPError:
|
|
||||||
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
|
|
||||||
if response.status_code in {401, 403}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
try:
|
|
||||||
response.raise_for_status()
|
|
||||||
body = response.json()
|
|
||||||
except (httpx.HTTPError, ValueError):
|
|
||||||
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
|
|
||||||
if isinstance(body, dict) and body.get("active") is False:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
_introspection_cache[identity.m2gpt_token] = now
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config")
|
|
||||||
def config() -> dict[str, str]:
|
|
||||||
return {"auth_mode": _auth_mode()}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def login(body: LoginBody, response: Response) -> None:
|
|
||||||
mode = _auth_mode()
|
|
||||||
if body.passcode is not None:
|
|
||||||
if mode not in {"passcode", "both"}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
if not hmac.compare_digest(body.passcode, _configured_passcode()):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
_set_session(response, _passcode_identity())
|
|
||||||
return
|
|
||||||
|
|
||||||
if body.email and body.password:
|
|
||||||
if mode not in {"gpt", "both"}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
_set_session(response, await _gpt_login(body.email, body.password))
|
|
||||||
return
|
|
||||||
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_login_body")
|
|
||||||
|
|
||||||
|
|
||||||
async def require_session(
|
|
||||||
session_cookie: Annotated[str | None, Cookie(alias=COOKIE_NAME)] = None,
|
session_cookie: Annotated[str | None, Cookie(alias=COOKIE_NAME)] = None,
|
||||||
) -> SessionIdentity:
|
) -> None:
|
||||||
if not session_cookie:
|
if not session_cookie:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
||||||
try:
|
try:
|
||||||
|
|
@ -237,23 +58,8 @@ async def require_session(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
|
||||||
) from None
|
) from None
|
||||||
|
if payload.get("scope") != "fleet":
|
||||||
if payload.get("scope") == "fleet":
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
||||||
identity = _passcode_identity()
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
identity = SessionIdentity.model_validate(payload)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
|
|
||||||
) from None
|
|
||||||
await _revalidate_with_m2gpt(identity)
|
|
||||||
return identity
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/session")
|
|
||||||
async def session(identity: Annotated[SessionIdentity, Depends(require_session)]) -> dict[str, Any]:
|
|
||||||
return identity.public()
|
|
||||||
|
|
||||||
|
|
||||||
SessionDependency = Depends(require_session)
|
SessionDependency = Depends(require_session)
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ from datetime import date
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from .auth import SessionDependency, SessionIdentity, require_session
|
from .auth import SessionDependency
|
||||||
from .http import client, upstream_error
|
from .http import client, upstream_error
|
||||||
|
|
||||||
router = APIRouter(dependencies=[SessionDependency])
|
router = APIRouter(dependencies=[SessionDependency])
|
||||||
|
|
@ -61,11 +61,7 @@ async def _latest_audit_date() -> str:
|
||||||
|
|
||||||
|
|
||||||
@router.get("/wallet/{operator_id}", response_model=None)
|
@router.get("/wallet/{operator_id}", response_model=None)
|
||||||
async def wallet(
|
async def wallet(operator_id: str) -> dict[str, Any] | JSONResponse:
|
||||||
operator_id: str, session: SessionIdentity = Depends(require_session)
|
|
||||||
) -> dict[str, Any] | JSONResponse:
|
|
||||||
if not session.fleet_admin and operator_id != session.operator_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden")
|
|
||||||
try:
|
try:
|
||||||
async with client(os.environ["LEDGER_URL"], _headers()) as http:
|
async with client(os.environ["LEDGER_URL"], _headers()) as http:
|
||||||
balance = await http.get(f"/balance/{operator_id}")
|
balance = await http.get(f"/balance/{operator_id}")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import base64
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -16,8 +15,6 @@ SECRET_VALUES = {
|
||||||
"mem-secret-key",
|
"mem-secret-key",
|
||||||
"ledger-secret-key",
|
"ledger-secret-key",
|
||||||
"forgejo-secret-token",
|
"forgejo-secret-token",
|
||||||
"m2gpt-password-secret",
|
|
||||||
"m2gpt-introspection-secret",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -33,8 +30,6 @@ def env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setenv("FORGEJO_TOKEN", "forgejo-secret-token")
|
monkeypatch.setenv("FORGEJO_TOKEN", "forgejo-secret-token")
|
||||||
monkeypatch.setenv("REGISTRY_REPO", "m2/market-registry")
|
monkeypatch.setenv("REGISTRY_REPO", "m2/market-registry")
|
||||||
monkeypatch.setenv("TENANT", "m2-core")
|
monkeypatch.setenv("TENANT", "m2-core")
|
||||||
monkeypatch.setenv("M2GPT_URL", "https://gpt.example")
|
|
||||||
monkeypatch.setenv("M2GPT_INTROSPECTION_KEY", "m2gpt-introspection-secret")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -51,14 +46,6 @@ def _authed(test_client: TestClient) -> None:
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
def _jwt(claims: dict) -> str:
|
|
||||||
def encode(part: dict) -> str:
|
|
||||||
raw = json.dumps(part, separators=(",", ":")).encode()
|
|
||||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
||||||
|
|
||||||
return f"{encode({'alg': 'none'})}.{encode(claims)}."
|
|
||||||
|
|
||||||
|
|
||||||
def _listing() -> dict:
|
def _listing() -> dict:
|
||||||
return {
|
return {
|
||||||
"name": "PDF Export",
|
"name": "PDF Export",
|
||||||
|
|
@ -100,17 +87,6 @@ def _ok_transport(request: httpx.Request) -> httpx.Response:
|
||||||
)
|
)
|
||||||
if request.url.host == "ledger.example":
|
if request.url.host == "ledger.example":
|
||||||
assert request.headers["X-API-Key"] == "ledger-secret-key"
|
assert request.headers["X-API-Key"] == "ledger-secret-key"
|
||||||
if request.url.path == "/balance/tenant-acme":
|
|
||||||
return httpx.Response(
|
|
||||||
200,
|
|
||||||
json={
|
|
||||||
"operator_id": "tenant-acme",
|
|
||||||
"balance": 31,
|
|
||||||
"as_of": "2026-07-02T10:00:00Z",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if request.url.path == "/balance/other-tenant":
|
|
||||||
return httpx.Response(200, json={"operator_id": "other-tenant", "balance": 13})
|
|
||||||
if request.url.path == "/balance/m2bd":
|
if request.url.path == "/balance/m2bd":
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
|
|
@ -135,23 +111,6 @@ def _ok_transport(request: httpx.Request) -> httpx.Response:
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if request.url.path == "/tx" and request.url.params.get("operator_id") == "tenant-acme":
|
|
||||||
return httpx.Response(
|
|
||||||
200,
|
|
||||||
json={
|
|
||||||
"transactions": [
|
|
||||||
{
|
|
||||||
"ts": "2026-07-02T09:00:00Z",
|
|
||||||
"from_id": "mint",
|
|
||||||
"to_id": "tenant-acme",
|
|
||||||
"amount": 31,
|
|
||||||
"reason": "grant",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if request.url.path == "/tx" and request.url.params.get("operator_id") == "other-tenant":
|
|
||||||
return httpx.Response(200, json={"transactions": []})
|
|
||||||
if request.url.path == "/tx":
|
if request.url.path == "/tx":
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
|
|
@ -189,55 +148,6 @@ def _ok_transport(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(404, json={"unexpected": str(request.url)})
|
return httpx.Response(404, json={"unexpected": str(request.url)})
|
||||||
|
|
||||||
|
|
||||||
def _gpt_transport(request: httpx.Request) -> httpx.Response:
|
|
||||||
if request.url.host == "gpt.example":
|
|
||||||
if request.url.path == "/admin/v1/auth/login":
|
|
||||||
body = json.loads(request.content)
|
|
||||||
if (
|
|
||||||
body == {
|
|
||||||
"email": "operator@example.com",
|
|
||||||
"password": "m2gpt-password-secret",
|
|
||||||
}
|
|
||||||
):
|
|
||||||
return httpx.Response(
|
|
||||||
200,
|
|
||||||
json={
|
|
||||||
"access_token": _jwt(
|
|
||||||
{
|
|
||||||
"email": "operator@example.com",
|
|
||||||
"role": "operator",
|
|
||||||
"tenant_id": "tenant-acme",
|
|
||||||
"fleet_admin": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
body == {
|
|
||||||
"email": "admin@example.com",
|
|
||||||
"password": "m2gpt-password-secret",
|
|
||||||
}
|
|
||||||
):
|
|
||||||
return httpx.Response(
|
|
||||||
200,
|
|
||||||
json={
|
|
||||||
"access_token": _jwt(
|
|
||||||
{
|
|
||||||
"email": "admin@example.com",
|
|
||||||
"role": "fleet_admin",
|
|
||||||
"tenant_id": "admin-tenant",
|
|
||||||
"fleet_admin": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return httpx.Response(401, json={"error": "bad_credentials"})
|
|
||||||
if request.url.path == "/admin/v1/auth/introspect":
|
|
||||||
assert request.headers["X-Introspection-Key"] == "m2gpt-introspection-secret"
|
|
||||||
return httpx.Response(200, json={"active": True})
|
|
||||||
return _ok_transport(request)
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_and_auth_gate(client: Callable[[httpx.MockTransport], TestClient]) -> None:
|
def test_login_and_auth_gate(client: Callable[[httpx.MockTransport], TestClient]) -> None:
|
||||||
test_client = client(httpx.MockTransport(_ok_transport))
|
test_client = client(httpx.MockTransport(_ok_transport))
|
||||||
assert test_client.get("/api/search?q=pdf").status_code == 401
|
assert test_client.get("/api/search?q=pdf").status_code == 401
|
||||||
|
|
@ -246,92 +156,6 @@ def test_login_and_auth_gate(client: Callable[[httpx.MockTransport], TestClient]
|
||||||
assert test_client.get("/api/search?q=pdf&limit=2").status_code == 200
|
assert test_client.get("/api/search?q=pdf&limit=2").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_auth_config_defaults_to_both(client: Callable[[httpx.MockTransport], TestClient]) -> None:
|
|
||||||
test_client = client(httpx.MockTransport(_ok_transport))
|
|
||||||
response = test_client.get("/api/config")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"auth_mode": "both"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_gpt_login_binds_session_and_wallet(
|
|
||||||
client: Callable[[httpx.MockTransport], TestClient],
|
|
||||||
) -> None:
|
|
||||||
test_client = client(httpx.MockTransport(_gpt_transport))
|
|
||||||
response = test_client.post(
|
|
||||||
"/api/login",
|
|
||||||
json={"email": "operator@example.com", "password": "m2gpt-password-secret"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
session = test_client.get("/api/session")
|
|
||||||
assert session.status_code == 200
|
|
||||||
assert session.json() == {
|
|
||||||
"email": "operator@example.com",
|
|
||||||
"role": "operator",
|
|
||||||
"tenant_id": "tenant-acme",
|
|
||||||
"fleet_admin": False,
|
|
||||||
"operator_id": "tenant-acme",
|
|
||||||
"auth_method": "gpt",
|
|
||||||
}
|
|
||||||
|
|
||||||
wallet = test_client.get("/api/wallet/tenant-acme")
|
|
||||||
assert wallet.status_code == 200
|
|
||||||
assert wallet.json()["balance"] == 31
|
|
||||||
|
|
||||||
|
|
||||||
def test_gpt_login_wrong_password_401(
|
|
||||||
client: Callable[[httpx.MockTransport], TestClient],
|
|
||||||
) -> None:
|
|
||||||
test_client = client(httpx.MockTransport(_gpt_transport))
|
|
||||||
response = test_client.post(
|
|
||||||
"/api/login",
|
|
||||||
json={"email": "operator@example.com", "password": "wrong"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_gpt_non_admin_foreign_wallet_403(
|
|
||||||
client: Callable[[httpx.MockTransport], TestClient],
|
|
||||||
) -> None:
|
|
||||||
test_client = client(httpx.MockTransport(_gpt_transport))
|
|
||||||
response = test_client.post(
|
|
||||||
"/api/login",
|
|
||||||
json={"email": "operator@example.com", "password": "m2gpt-password-secret"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 204
|
|
||||||
assert test_client.get("/api/wallet/other-tenant").status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_gpt_fleet_admin_can_view_foreign_wallet(
|
|
||||||
client: Callable[[httpx.MockTransport], TestClient],
|
|
||||||
) -> None:
|
|
||||||
test_client = client(httpx.MockTransport(_gpt_transport))
|
|
||||||
response = test_client.post(
|
|
||||||
"/api/login",
|
|
||||||
json={"email": "admin@example.com", "password": "m2gpt-password-secret"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 204
|
|
||||||
wallet = test_client.get("/api/wallet/other-tenant")
|
|
||||||
assert wallet.status_code == 200
|
|
||||||
assert wallet.json()["balance"] == 13
|
|
||||||
|
|
||||||
|
|
||||||
def test_passcode_mode_still_allows_passcode_only(
|
|
||||||
client: Callable[[httpx.MockTransport], TestClient], monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setenv("AUTH_MODE", "passcode")
|
|
||||||
test_client = client(httpx.MockTransport(_gpt_transport))
|
|
||||||
assert (
|
|
||||||
test_client.post(
|
|
||||||
"/api/login",
|
|
||||||
json={"email": "operator@example.com", "password": "m2gpt-password-secret"},
|
|
||||||
).status_code
|
|
||||||
== 401
|
|
||||||
)
|
|
||||||
_authed(test_client)
|
|
||||||
assert test_client.get("/api/wallet/m2bd").status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_catalog_wallet_economy_governance_happy_paths(
|
def test_catalog_wallet_economy_governance_happy_paths(
|
||||||
client: Callable[[httpx.MockTransport], TestClient],
|
client: Callable[[httpx.MockTransport], TestClient],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -56,21 +56,6 @@ export type Governance = {
|
||||||
listings: Array<{ listing_id: string; status: string; url: string }>;
|
listings: Array<{ listing_id: string; status: string; url: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthMode = 'passcode' | 'gpt' | 'both';
|
|
||||||
|
|
||||||
export type Config = {
|
|
||||||
auth_mode: AuthMode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Session = {
|
|
||||||
email?: string | null;
|
|
||||||
role: string;
|
|
||||||
tenant_id?: string | null;
|
|
||||||
fleet_admin: boolean;
|
|
||||||
operator_id: string;
|
|
||||||
auth_method: 'passcode' | 'gpt';
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PanelName = 'search' | 'listing' | 'wallet' | 'economy' | 'governance' | 'login';
|
export type PanelName = 'search' | 'listing' | 'wallet' | 'economy' | 'governance' | 'login';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|
@ -126,18 +111,6 @@ export const api = {
|
||||||
body: JSON.stringify({ passcode }),
|
body: JSON.stringify({ passcode }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
loginGpt(email: string, password: string) {
|
|
||||||
return request<void>('/api/login', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
config() {
|
|
||||||
return request<Config>('/api/config');
|
|
||||||
},
|
|
||||||
session() {
|
|
||||||
return request<Session>('/api/session');
|
|
||||||
},
|
|
||||||
search(q: string, limit = 10) {
|
search(q: string, limit = 10) {
|
||||||
const params = new URLSearchParams({ q, limit: String(limit) });
|
const params = new URLSearchParams({ q, limit: String(limit) });
|
||||||
return request<ListingSummary[]>(`/api/search?${params}`);
|
return request<ListingSummary[]>(`/api/search?${params}`);
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@ import React, { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
AuthMode,
|
|
||||||
Economy,
|
Economy,
|
||||||
Governance,
|
Governance,
|
||||||
ListingDetail,
|
ListingDetail,
|
||||||
ListingSummary,
|
ListingSummary,
|
||||||
PanelName,
|
PanelName,
|
||||||
Session,
|
|
||||||
Wallet,
|
Wallet,
|
||||||
api,
|
api,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
|
@ -98,32 +96,11 @@ function PanelError({ panel, error }: { panel: PanelName; error: unknown }) {
|
||||||
function App() {
|
function App() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const [operator, setOperator] = useState(() => localStorage.getItem(OPERATOR_STORAGE_KEY) || OPERATOR_PRESETS[0]);
|
const [operator, setOperator] = useState(() => localStorage.getItem(OPERATOR_STORAGE_KEY) || OPERATOR_PRESETS[0]);
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(OPERATOR_STORAGE_KEY, operator);
|
localStorage.setItem(OPERATOR_STORAGE_KEY, operator);
|
||||||
}, [operator]);
|
}, [operator]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (route.name === 'login') return;
|
|
||||||
let cancelled = false;
|
|
||||||
api
|
|
||||||
.session()
|
|
||||||
.then((payload) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setSession(payload);
|
|
||||||
if (!payload.fleet_admin) setOperator(payload.operator_id);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setSession(null);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [route.name]);
|
|
||||||
|
|
||||||
const walletOperator = session && !session.fleet_admin ? session.operator_id : operator;
|
|
||||||
|
|
||||||
if (route.name === 'login') {
|
if (route.name === 'login') {
|
||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
@ -144,13 +121,13 @@ function App() {
|
||||||
<NavLink href="#/economy" active={route.name === 'economy'} label="Economy" />
|
<NavLink href="#/economy" active={route.name === 'economy'} label="Economy" />
|
||||||
<NavLink href="#/governance" active={route.name === 'governance'} label="Governance" />
|
<NavLink href="#/governance" active={route.name === 'governance'} label="Governance" />
|
||||||
</nav>
|
</nav>
|
||||||
{session?.fleet_admin && <OperatorPicker value={operator} onChange={setOperator} />}
|
<OperatorPicker value={operator} onChange={setOperator} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="main-grid">
|
<main className="main-grid">
|
||||||
{route.name === 'search' && <SearchPage />}
|
{route.name === 'search' && <SearchPage />}
|
||||||
{route.name === 'listing' && <ListingPage listingId={route.id} />}
|
{route.name === 'listing' && <ListingPage listingId={route.id} />}
|
||||||
{route.name === 'wallet' && <WalletPage operator={walletOperator} />}
|
{route.name === 'wallet' && <WalletPage operator={operator} />}
|
||||||
{route.name === 'economy' && <EconomyPage />}
|
{route.name === 'economy' && <EconomyPage />}
|
||||||
{route.name === 'governance' && <GovernancePage />}
|
{route.name === 'governance' && <GovernancePage />}
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -168,30 +145,12 @@ function NavLink({ href, active, label }: { href: string; active: boolean; label
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const [passcode, setPasscode] = useState('');
|
const [passcode, setPasscode] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [authMode, setAuthMode] = useState<AuthMode>('both');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState<'passcode' | 'gpt' | null>(null);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
async function submit(event: FormEvent) {
|
||||||
let cancelled = false;
|
|
||||||
api
|
|
||||||
.config()
|
|
||||||
.then((payload) => {
|
|
||||||
if (!cancelled) setAuthMode(payload.auth_mode);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setAuthMode('both');
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function submitPasscode(event: FormEvent) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setBusy('passcode');
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -200,28 +159,10 @@ function LoginPage() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError && err.status === 401 ? 'Passcode rejected.' : 'Login failed.');
|
setError(err instanceof ApiError && err.status === 401 ? 'Passcode rejected.' : 'Login failed.');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitGpt(event: FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
setBusy('gpt');
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.loginGpt(email, password);
|
|
||||||
window.location.hash = '#/';
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof ApiError && err.status === 401 ? 'Credentials rejected.' : 'Login failed.');
|
|
||||||
} finally {
|
|
||||||
setBusy(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const showPasscode = authMode === 'passcode' || authMode === 'both';
|
|
||||||
const showGpt = authMode === 'gpt' || authMode === 'both';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="login-page">
|
<main className="login-page">
|
||||||
<section className="login-panel">
|
<section className="login-panel">
|
||||||
|
|
@ -232,49 +173,19 @@ function LoginPage() {
|
||||||
<small>machine.machine</small>
|
<small>machine.machine</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="login-options">
|
<form onSubmit={submit} className="login-form">
|
||||||
{showGpt && (
|
<label htmlFor="passcode">Fleet passcode</label>
|
||||||
<form onSubmit={submitGpt} className="login-form">
|
<input
|
||||||
<label htmlFor="email">m2-gpt email</label>
|
id="passcode"
|
||||||
<input
|
autoFocus
|
||||||
id="email"
|
type="password"
|
||||||
autoFocus={showGpt}
|
value={passcode}
|
||||||
type="email"
|
onChange={(event) => setPasscode(event.target.value)}
|
||||||
value={email}
|
autoComplete="current-password"
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
/>
|
||||||
autoComplete="username"
|
{error && <p className="form-error">{error}</p>}
|
||||||
/>
|
<button disabled={busy || passcode.trim().length === 0}>{busy ? 'Checking...' : 'Enter'}</button>
|
||||||
<label htmlFor="password">m2-gpt password</label>
|
</form>
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
<button disabled={busy !== null || email.trim().length === 0 || password.length === 0}>
|
|
||||||
{busy === 'gpt' ? 'Checking...' : 'Sign in'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
{showPasscode && (
|
|
||||||
<form onSubmit={submitPasscode} className="login-form">
|
|
||||||
<label htmlFor="passcode">Fleet passcode</label>
|
|
||||||
<input
|
|
||||||
id="passcode"
|
|
||||||
autoFocus={!showGpt}
|
|
||||||
type="password"
|
|
||||||
value={passcode}
|
|
||||||
onChange={(event) => setPasscode(event.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
<button disabled={busy !== null || passcode.trim().length === 0}>
|
|
||||||
{busy === 'passcode' ? 'Checking...' : 'Enter'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{error && <p className="form-error">{error}</p>}
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -450,20 +450,13 @@ td {
|
||||||
.login-panel {
|
.login-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
width: min(100%, 520px);
|
width: min(100%, 420px);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-options {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form label {
|
.login-form label {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue