Compare commits
2 commits
5dd73eb4cd
...
dcb97f180f
| Author | SHA1 | Date | |
|---|---|---|---|
| dcb97f180f | |||
| a77b0dbaef |
7 changed files with 536 additions and 37 deletions
|
|
@ -1,11 +1,13 @@
|
||||||
# Contract: m2-market-web /api (v1)
|
# Contract: m2-market-web /api (v1)
|
||||||
|
|
||||||
Session: POST /api/login {passcode} → 204 + Set-Cookie m2mw_session (signed, 7d) | 401.
|
Config: GET /api/config → {auth_mode:"passcode"|"gpt"|"both"}.
|
||||||
|
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}]}
|
- 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/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,21 +1,47 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Annotated
|
import time
|
||||||
|
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
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
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
|
passcode: str | None = None
|
||||||
|
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:
|
||||||
|
|
@ -25,6 +51,11 @@ 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:
|
||||||
|
|
@ -32,11 +63,81 @@ def _configured_passcode() -> str:
|
||||||
return passcode
|
return passcode
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
|
def _operator_id(email: str | None, tenant_id: str | None) -> str:
|
||||||
def login(body: LoginBody, response: Response) -> None:
|
if tenant_id:
|
||||||
if not hmac.compare_digest(body.passcode, _configured_passcode()):
|
return tenant_id
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
if email and "@" in email:
|
||||||
token = _serializer().dumps({"scope": "fleet"})
|
return email.split("@", 1)[0]
|
||||||
|
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,
|
||||||
|
|
@ -47,9 +148,87 @@ def login(body: LoginBody, response: Response) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def require_session(
|
async def _gpt_login(email: str, password: str) -> SessionIdentity:
|
||||||
|
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,
|
||||||
) -> None:
|
) -> SessionIdentity:
|
||||||
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:
|
||||||
|
|
@ -58,8 +237,23 @@ 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":
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
if payload.get("scope") == "fleet":
|
||||||
|
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
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from .auth import SessionDependency
|
from .auth import SessionDependency, SessionIdentity, require_session
|
||||||
from .http import client, upstream_error
|
from .http import client, upstream_error
|
||||||
|
|
||||||
router = APIRouter(dependencies=[SessionDependency])
|
router = APIRouter(dependencies=[SessionDependency])
|
||||||
|
|
@ -61,7 +61,11 @@ 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(operator_id: str) -> dict[str, Any] | JSONResponse:
|
async def wallet(
|
||||||
|
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,6 +1,7 @@
|
||||||
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
|
||||||
|
|
@ -15,6 +16,8 @@ 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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -30,6 +33,8 @@ 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
|
||||||
|
|
@ -46,6 +51,14 @@ 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",
|
||||||
|
|
@ -87,6 +100,17 @@ 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,
|
||||||
|
|
@ -111,6 +135,23 @@ 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,
|
||||||
|
|
@ -148,6 +189,55 @@ 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
|
||||||
|
|
@ -156,6 +246,92 @@ 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,6 +56,21 @@ 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 {
|
||||||
|
|
@ -111,6 +126,18 @@ 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,11 +2,13 @@ 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';
|
||||||
|
|
@ -96,11 +98,32 @@ 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 />;
|
||||||
}
|
}
|
||||||
|
|
@ -121,13 +144,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>
|
||||||
<OperatorPicker value={operator} onChange={setOperator} />
|
{session?.fleet_admin && <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={operator} />}
|
{route.name === 'wallet' && <WalletPage operator={walletOperator} />}
|
||||||
{route.name === 'economy' && <EconomyPage />}
|
{route.name === 'economy' && <EconomyPage />}
|
||||||
{route.name === 'governance' && <GovernancePage />}
|
{route.name === 'governance' && <GovernancePage />}
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -145,12 +168,30 @@ 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(false);
|
const [busy, setBusy] = useState<'passcode' | 'gpt' | null>(null);
|
||||||
|
|
||||||
async function submit(event: FormEvent) {
|
useEffect(() => {
|
||||||
|
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(true);
|
setBusy('passcode');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -159,10 +200,28 @@ 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(false);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
|
@ -173,19 +232,49 @@ function LoginPage() {
|
||||||
<small>machine.machine</small>
|
<small>machine.machine</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={submit} className="login-form">
|
<div className="login-options">
|
||||||
<label htmlFor="passcode">Fleet passcode</label>
|
{showGpt && (
|
||||||
<input
|
<form onSubmit={submitGpt} className="login-form">
|
||||||
id="passcode"
|
<label htmlFor="email">m2-gpt email</label>
|
||||||
autoFocus
|
<input
|
||||||
type="password"
|
id="email"
|
||||||
value={passcode}
|
autoFocus={showGpt}
|
||||||
onChange={(event) => setPasscode(event.target.value)}
|
type="email"
|
||||||
autoComplete="current-password"
|
value={email}
|
||||||
/>
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
{error && <p className="form-error">{error}</p>}
|
autoComplete="username"
|
||||||
<button disabled={busy || passcode.trim().length === 0}>{busy ? 'Checking...' : 'Enter'}</button>
|
/>
|
||||||
</form>
|
<label htmlFor="password">m2-gpt password</label>
|
||||||
|
<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,13 +450,20 @@ td {
|
||||||
.login-panel {
|
.login-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
width: min(100%, 420px);
|
width: min(100%, 520px);
|
||||||
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