Compare commits

..

5 commits

22 changed files with 3855 additions and 2 deletions

3
.gitignore vendored
View file

@ -5,3 +5,6 @@ __pycache__/
.venv/ .venv/
uv.lock uv.lock
.pytest_cache/ .pytest_cache/
web/frontend/node_modules/
web/frontend/dist/

View file

@ -1 +1 @@
{"feature_directory":"specs/001-market-first-wedge"} {"feature_directory":"specs/002-market-web"}

View file

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

24
web/Dockerfile Normal file
View file

@ -0,0 +1,24 @@
# m2-market-web — FastAPI proxy + React SPA in one container (specs/002-market-web).
# Build context: repo root (needs web/backend + web/frontend).
FROM node:20-slim AS frontend
WORKDIR /fe
COPY web/frontend/package.json web/frontend/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY web/frontend/ ./
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
COPY web/backend/pyproject.toml ./backend/pyproject.toml
COPY web/backend/src ./backend/src
RUN pip install --no-cache-dir ./backend
COPY --from=frontend /fe/dist ./frontend/dist
ENV M2MW_STATIC_DIR=/app/frontend/dist
RUN groupadd --system web && useradd --system --gid web web
USER web
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)" || exit 1
CMD ["uvicorn", "m2_market_web.main:app", "--host", "0.0.0.0", "--port", "8000"]

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,49 @@
from __future__ import annotations
import os
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"}
# In the container the package is pip-installed (site-packages), so the
# repo-relative parents[3] fallback misses — M2MW_STATIC_DIR wins.
dist = Path(
os.environ.get(
"M2MW_STATIC_DIR",
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

13
web/frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f1830" />
<title>M2 Market</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1729
web/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
web/frontend/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "m2-market-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

130
web/frontend/src/api.ts Normal file
View file

@ -0,0 +1,130 @@
export type Price = {
amount: number;
currency: string;
model: string;
};
export type ListingStats = {
installs?: number;
proposals_shown?: number;
proposals_accepted?: number;
};
export type ListingSummary = {
listing_id: string;
name: string;
summary: string;
category?: string;
price: Price;
seller: string;
stats?: ListingStats;
score?: number;
};
export type ListingDetail = ListingSummary & {
evidence_summary: string;
permissions: string[];
install_ref?: string;
install_command: string;
solution_version?: string;
version?: string;
};
export type Transaction = {
ts: string;
from: string;
to: string;
amount: number;
reason: string;
ref?: string;
};
export type Wallet = {
operator_id: string;
balance: number;
as_of?: string;
transactions: Transaction[];
};
export type Economy = {
operators: Array<{ operator_id: string; balance: number }>;
audit?: { date: string; url: string };
};
export type Governance = {
open_prs: Array<{ number: number; title: string; age_days: number; labels: string[]; url: string }>;
listings: Array<{ listing_id: string; status: string; url: string }>;
};
export type PanelName = 'search' | 'listing' | 'wallet' | 'economy' | 'governance' | 'login';
export class ApiError extends Error {
status: number;
panel?: string;
constructor(message: string, status: number, panel?: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.panel = panel;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
...init,
});
if (response.status === 401) {
if (window.location.hash !== '#/login') {
window.location.hash = '#/login';
}
throw new ApiError('Login required', 401);
}
if (response.status === 502) {
const body = (await response.json().catch(() => null)) as { error?: string; panel?: string } | null;
throw new ApiError(body?.error ?? 'Panel is temporarily unavailable', 502, body?.panel);
}
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
throw new ApiError(body?.error ?? `Request failed (${response.status})`, response.status);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export const api = {
login(passcode: string) {
return request<void>('/api/login', {
method: 'POST',
body: JSON.stringify({ passcode }),
});
},
search(q: string, limit = 10) {
const params = new URLSearchParams({ q, limit: String(limit) });
return request<ListingSummary[]>(`/api/search?${params}`);
},
listing(listingId: string) {
return request<ListingDetail>(`/api/listing/${encodeURIComponent(listingId)}`);
},
wallet(operatorId: string) {
return request<Wallet>(`/api/wallet/${encodeURIComponent(operatorId)}`);
},
economy() {
return request<Economy>('/api/economy');
},
governance() {
return request<Governance>('/api/governance');
},
};

630
web/frontend/src/main.tsx Normal file
View file

@ -0,0 +1,630 @@
import React, { FormEvent, useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
ApiError,
Economy,
Governance,
ListingDetail,
ListingSummary,
PanelName,
Wallet,
api,
} from './api';
import './styles.css';
const OPERATOR_PRESETS = ['m2bd', 'sdjs-operator', 'chris-operator', 'gunnar-operator'];
const OPERATOR_STORAGE_KEY = 'm2-market.operator';
type Route =
| { name: 'login' }
| { name: 'search' }
| { name: 'listing'; id: string }
| { name: 'wallet' }
| { name: 'economy' }
| { name: 'governance' };
function routeFromHash(): Route {
const hash = window.location.hash.replace(/^#/, '') || '/';
const [path] = hash.split('?');
const parts = path.split('/').filter(Boolean);
if (parts[0] === 'login') return { name: 'login' };
if (parts[0] === 'listing' && parts[1]) return { name: 'listing', id: decodeURIComponent(parts[1]) };
if (parts[0] === 'wallet') return { name: 'wallet' };
if (parts[0] === 'economy') return { name: 'economy' };
if (parts[0] === 'governance') return { name: 'governance' };
return { name: 'search' };
}
function useRoute(): Route {
const [route, setRoute] = useState(routeFromHash);
useEffect(() => {
const onChange = () => setRoute(routeFromHash());
window.addEventListener('hashchange', onChange);
return () => window.removeEventListener('hashchange', onChange);
}, []);
return route;
}
function formatM2cr(value: number): string {
return `${new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value)} m2cr`;
}
function formatDate(value?: string): string {
if (!value) return 'unknown';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function getPanelError(error: unknown, panel: PanelName): string | null {
const panelAliases: Record<PanelName, string[]> = {
search: ['search', 'catalog'],
listing: ['listing', 'catalog'],
wallet: ['wallet', 'ledger'],
economy: ['economy', 'ledger'],
governance: ['governance', 'registry', 'forgejo'],
login: ['login'],
};
if (
error instanceof ApiError &&
error.status === 502 &&
(!error.panel || panelAliases[panel].includes(error.panel))
) {
return error.message;
}
if (error instanceof ApiError && error.status === 502) return null;
if (error instanceof ApiError && error.status !== 401) return error.message;
if (error instanceof Error) return error.message;
return null;
}
function PanelError({ panel, error }: { panel: PanelName; error: unknown }) {
const message = getPanelError(error, panel);
if (!message) return null;
return (
<div className="panel-error" role="status">
<strong>{panel} rail unavailable</strong>
<span>{message}</span>
</div>
);
}
function App() {
const route = useRoute();
const [operator, setOperator] = useState(() => localStorage.getItem(OPERATOR_STORAGE_KEY) || OPERATOR_PRESETS[0]);
useEffect(() => {
localStorage.setItem(OPERATOR_STORAGE_KEY, operator);
}, [operator]);
if (route.name === 'login') {
return <LoginPage />;
}
return (
<div className="app-shell">
<header className="topbar">
<a className="brand" href="#/">
<span className="brand-mark">m2</span>
<span>
<strong>market</strong>
<small>machine.machine</small>
</span>
</a>
<nav className="nav" aria-label="Primary">
<NavLink href="#/" active={route.name === 'search'} label="Search" />
<NavLink href="#/wallet" active={route.name === 'wallet'} label="Wallet" />
<NavLink href="#/economy" active={route.name === 'economy'} label="Economy" />
<NavLink href="#/governance" active={route.name === 'governance'} label="Governance" />
</nav>
<OperatorPicker value={operator} onChange={setOperator} />
</header>
<main className="main-grid">
{route.name === 'search' && <SearchPage />}
{route.name === 'listing' && <ListingPage listingId={route.id} />}
{route.name === 'wallet' && <WalletPage operator={operator} />}
{route.name === 'economy' && <EconomyPage />}
{route.name === 'governance' && <GovernancePage />}
</main>
</div>
);
}
function NavLink({ href, active, label }: { href: string; active: boolean; label: string }) {
return (
<a className={active ? 'active' : ''} href={href}>
{label}
</a>
);
}
function LoginPage() {
const [passcode, setPasscode] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
try {
await api.login(passcode);
window.location.hash = '#/';
} catch (err) {
setError(err instanceof ApiError && err.status === 401 ? 'Passcode rejected.' : 'Login failed.');
} finally {
setBusy(false);
}
}
return (
<main className="login-page">
<section className="login-panel">
<div className="brand login-brand">
<span className="brand-mark">m2</span>
<span>
<strong>market</strong>
<small>machine.machine</small>
</span>
</div>
<form onSubmit={submit} className="login-form">
<label htmlFor="passcode">Fleet passcode</label>
<input
id="passcode"
autoFocus
type="password"
value={passcode}
onChange={(event) => setPasscode(event.target.value)}
autoComplete="current-password"
/>
{error && <p className="form-error">{error}</p>}
<button disabled={busy || passcode.trim().length === 0}>{busy ? 'Checking...' : 'Enter'}</button>
</form>
</section>
</main>
);
}
function OperatorPicker({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const presetValue = OPERATOR_PRESETS.includes(value) ? value : '';
return (
<section className="operator-picker" aria-label="Operator picker">
<select
value={presetValue}
onChange={(event) => {
if (event.target.value) onChange(event.target.value);
}}
>
<option value="" disabled>
custom
</option>
{OPERATOR_PRESETS.map((operator) => (
<option key={operator} value={operator}>
{operator}
</option>
))}
</select>
<input value={value} onChange={(event) => onChange(event.target.value)} aria-label="Operator id" />
</section>
);
}
function SearchPage() {
const [query, setQuery] = useState('pdf report');
const [submittedQuery, setSubmittedQuery] = useState('pdf report');
const [results, setResults] = useState<ListingSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api
.search(submittedQuery, 10)
.then((payload) => {
if (!cancelled) setResults(payload);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [submittedQuery]);
function submit(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
}
return (
<section className="view">
<div className="view-heading">
<p>Catalog</p>
<h1>Search listings</h1>
</div>
<form className="search-bar" onSubmit={submit}>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search catalog" />
<button>Search</button>
</form>
<PanelError panel="search" error={error} />
{loading && <div className="empty-state">Loading catalog results...</div>}
{!loading && !error && results.length === 0 && <div className="empty-state">No visible listings matched.</div>}
<div className="cards-grid">
{results.map((listing) => (
<ListingCard key={listing.listing_id} listing={listing} />
))}
</div>
</section>
);
}
function ListingCard({ listing }: { listing: ListingSummary }) {
return (
<article className="listing-card">
<div>
<p className="eyebrow">{listing.category || 'solution'}</p>
<h2>{listing.name}</h2>
<p>{listing.summary}</p>
</div>
<dl className="metrics">
<div>
<dt>price</dt>
<dd>{formatM2cr(listing.price.amount)}</dd>
</div>
<div>
<dt>installs</dt>
<dd>{listing.stats?.installs ?? 0}</dd>
</div>
</dl>
<a className="text-link" href={`#/listing/${encodeURIComponent(listing.listing_id)}`}>
Open listing
</a>
</article>
);
}
function ListingPage({ listingId }: { listingId: string }) {
const [listing, setListing] = useState<ListingDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api
.listing(listingId)
.then((payload) => {
if (!cancelled) setListing(payload);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [listingId]);
async function copyCommand(command: string) {
await navigator.clipboard.writeText(command);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
}
return (
<section className="view">
<a className="text-link back-link" href="#/">
Back to search
</a>
<PanelError panel="listing" error={error} />
{loading && <div className="empty-state">Loading listing...</div>}
{listing && (
<>
<div className="listing-hero">
<p className="eyebrow">{listing.category || listing.listing_id}</p>
<h1>{listing.name}</h1>
<p>{listing.summary}</p>
<dl className="metrics wide">
<div>
<dt>seller</dt>
<dd>{listing.seller}</dd>
</div>
<div>
<dt>version</dt>
<dd>{listing.version || listing.solution_version || 'unknown'}</dd>
</div>
<div>
<dt>price</dt>
<dd>{formatM2cr(listing.price.amount)}</dd>
</div>
<div>
<dt>installs</dt>
<dd>{listing.stats?.installs ?? 0}</dd>
</div>
</dl>
</div>
<div className="two-column">
<section className="surface">
<p className="eyebrow">Evidence</p>
<p>{listing.evidence_summary}</p>
</section>
<section className="surface">
<p className="eyebrow">Permissions</p>
{listing.permissions.length === 0 ? (
<p>No extra permissions declared.</p>
) : (
<ul className="plain-list">
{listing.permissions.map((permission) => (
<li key={permission}>{permission}</li>
))}
</ul>
)}
</section>
</div>
<section className="handoff">
<div>
<p className="eyebrow">Install handoff</p>
<h2>Run this on your desktop terminal</h2>
{listing.install_ref && <p>Install ref: {listing.install_ref}</p>}
</div>
<code>{listing.install_command}</code>
<button onClick={() => copyCommand(listing.install_command)}>{copied ? 'Copied' : 'Copy command'}</button>
</section>
</>
)}
</section>
);
}
function WalletPage({ operator }: { operator: string }) {
const [wallet, setWallet] = useState<Wallet | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api
.wallet(operator.trim() || 'unknown')
.then((payload) => {
if (!cancelled) setWallet(payload);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [operator]);
return (
<section className="view">
<div className="view-heading">
<p>Wallet</p>
<h1>{operator || 'unknown operator'}</h1>
</div>
<PanelError panel="wallet" error={error} />
{loading && <div className="empty-state">Loading wallet...</div>}
{wallet && (
<>
<section className="balance-strip">
<div>
<span>balance</span>
<strong>{formatM2cr(wallet.balance)}</strong>
</div>
<p>as of {formatDate(wallet.as_of)}</p>
</section>
{wallet.balance === 0 && wallet.transactions.length === 0 && (
<div className="empty-state">No such wallet yet, or the wallet has no activity.</div>
)}
<TransactionTable transactions={wallet.transactions} />
</>
)}
</section>
);
}
function TransactionTable({ transactions }: { transactions: Wallet['transactions'] }) {
if (transactions.length === 0) return null;
return (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Time</th>
<th>From</th>
<th>To</th>
<th>Amount</th>
<th>Reason</th>
<th>Ref</th>
</tr>
</thead>
<tbody>
{transactions.map((tx, index) => (
<tr key={`${tx.ts}-${tx.ref ?? index}`}>
<td>{formatDate(tx.ts)}</td>
<td>{tx.from}</td>
<td>{tx.to}</td>
<td>{formatM2cr(tx.amount)}</td>
<td>{tx.reason}</td>
<td>{tx.ref || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function EconomyPage() {
const [economy, setEconomy] = useState<Economy | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api
.economy()
.then((payload) => {
if (!cancelled) setEconomy(payload);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const total = useMemo(() => economy?.operators.reduce((sum, operator) => sum + operator.balance, 0) ?? 0, [economy]);
return (
<section className="view">
<div className="view-heading">
<p>Economy</p>
<h1>Fleet balances</h1>
</div>
<PanelError panel="economy" error={error} />
{loading && <div className="empty-state">Loading economy...</div>}
{economy && (
<>
<section className="balance-strip">
<div>
<span>total</span>
<strong>{formatM2cr(total)}</strong>
</div>
{economy.audit ? (
<a className="text-link" href={economy.audit.url} target="_blank" rel="noreferrer">
Audit snapshot {economy.audit.date}
</a>
) : (
<p>No audit snapshot linked.</p>
)}
</section>
<div className="cards-grid compact">
{economy.operators.map((operator) => (
<article className="mini-card" key={operator.operator_id}>
<span>{operator.operator_id}</span>
<strong>{formatM2cr(operator.balance)}</strong>
</article>
))}
</div>
</>
)}
</section>
);
}
function GovernancePage() {
const [governance, setGovernance] = useState<Governance | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api
.governance()
.then((payload) => {
if (!cancelled) setGovernance(payload);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<section className="view">
<div className="view-heading">
<p>Governance</p>
<h1>Registry review</h1>
</div>
<PanelError panel="governance" error={error} />
{loading && <div className="empty-state">Loading governance...</div>}
{governance && (
<div className="two-column">
<section className="surface">
<p className="eyebrow">Open PRs</p>
{governance.open_prs.length === 0 ? (
<p>No open listing PRs.</p>
) : (
<ul className="review-list">
{governance.open_prs.map((pr) => (
<li key={pr.number}>
<a href={pr.url} target="_blank" rel="noreferrer">
#{pr.number} {pr.title}
</a>
<span>{pr.age_days}d open</span>
<div className="labels">
{pr.labels.map((label) => (
<span key={label}>{label}</span>
))}
</div>
</li>
))}
</ul>
)}
</section>
<section className="surface">
<p className="eyebrow">Listings</p>
{governance.listings.length === 0 ? (
<p>No listings returned.</p>
) : (
<ul className="review-list">
{governance.listings.map((listing) => (
<li key={listing.listing_id}>
<a href={listing.url} target="_blank" rel="noreferrer">
{listing.listing_id}
</a>
<span className={`status ${listing.status}`}>{listing.status}</span>
</li>
))}
</ul>
)}
</section>
</div>
)}
</section>
);
}
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

521
web/frontend/src/styles.css Normal file
View file

@ -0,0 +1,521 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #eaf7ff;
background: #0f1830;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
--bg: #0f1830;
--bg-soft: #14213d;
--panel: #182642;
--panel-strong: #203456;
--line: rgba(129, 210, 232, 0.22);
--cyan: #2ee8ff;
--cyan-soft: #9df3ff;
--green: #70f1b5;
--red: #ff7c9a;
--text: #eaf7ff;
--muted: #9eb4c8;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
linear-gradient(180deg, rgba(46, 232, 255, 0.08), transparent 34rem),
radial-gradient(circle at top right, rgba(112, 241, 181, 0.12), transparent 26rem),
var(--bg);
}
button,
input,
select {
font: inherit;
}
a {
color: inherit;
}
.app-shell {
min-height: 100vh;
}
.topbar {
position: sticky;
top: 0;
z-index: 5;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
align-items: center;
padding: 1rem clamp(1rem, 3vw, 2rem);
border-bottom: 1px solid var(--line);
background: rgba(15, 24, 48, 0.9);
backdrop-filter: blur(18px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
color: var(--text);
text-decoration: none;
}
.brand-mark {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border: 1px solid var(--cyan);
background: rgba(46, 232, 255, 0.12);
color: var(--cyan);
font-weight: 800;
}
.brand strong,
.brand small {
display: block;
}
.brand small {
color: var(--muted);
font-size: 0.75rem;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: center;
}
.nav a,
.text-link {
color: var(--cyan-soft);
text-decoration: none;
}
.nav a {
padding: 0.6rem 0.75rem;
border: 1px solid transparent;
}
.nav a.active,
.nav a:hover {
border-color: var(--line);
background: rgba(46, 232, 255, 0.1);
}
.operator-picker {
display: grid;
grid-template-columns: minmax(8rem, auto) minmax(10rem, 14rem);
gap: 0.5rem;
}
input,
select {
min-width: 0;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.7rem 0.8rem;
background: rgba(7, 12, 24, 0.56);
color: var(--text);
outline: none;
}
input:focus,
select:focus {
border-color: var(--cyan);
box-shadow: 0 0 0 3px rgba(46, 232, 255, 0.14);
}
button {
border: 1px solid rgba(46, 232, 255, 0.58);
border-radius: 6px;
padding: 0.72rem 1rem;
background: var(--cyan);
color: #08111f;
font-weight: 800;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.main-grid {
width: min(1180px, calc(100vw - 2rem));
margin: 0 auto;
padding: clamp(1.5rem, 5vw, 4rem) 0;
}
.view {
display: grid;
gap: 1.25rem;
}
.view-heading p,
.eyebrow {
margin: 0 0 0.35rem;
color: var(--cyan);
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2,
p {
overflow-wrap: anywhere;
}
h1 {
margin: 0;
max-width: 840px;
font-size: clamp(2.15rem, 7vw, 4.75rem);
line-height: 0.96;
letter-spacing: 0;
}
h2 {
margin: 0 0 0.6rem;
font-size: 1.25rem;
letter-spacing: 0;
}
p {
color: var(--muted);
line-height: 1.6;
}
.search-bar {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
max-width: 760px;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 1rem;
}
.cards-grid.compact {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}
.listing-card,
.surface,
.mini-card,
.handoff,
.balance-strip,
.listing-hero,
.login-panel {
border: 1px solid var(--line);
border-radius: 8px;
background: linear-gradient(180deg, rgba(32, 52, 86, 0.9), rgba(20, 33, 61, 0.82));
box-shadow: var(--shadow);
}
.listing-card {
display: grid;
gap: 1rem;
min-height: 20rem;
padding: 1.25rem;
}
.metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.metrics.wide {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.metrics div,
.mini-card {
padding: 0.85rem;
border: 1px solid rgba(157, 243, 255, 0.16);
background: rgba(7, 12, 24, 0.34);
}
dt,
.mini-card span,
.balance-strip span {
color: var(--muted);
font-size: 0.75rem;
text-transform: uppercase;
}
dd,
.mini-card strong,
.balance-strip strong {
display: block;
margin: 0.2rem 0 0;
color: var(--text);
font-size: 1.15rem;
font-weight: 800;
}
.listing-hero,
.surface,
.handoff,
.balance-strip {
padding: clamp(1rem, 3vw, 1.5rem);
}
.listing-hero {
display: grid;
gap: 1rem;
}
.listing-hero p {
max-width: 860px;
}
.two-column {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.plain-list {
margin: 0;
padding-left: 1.2rem;
color: var(--muted);
line-height: 1.8;
}
.handoff {
display: grid;
grid-template-columns: 1fr;
gap: 0.9rem;
background: linear-gradient(135deg, rgba(46, 232, 255, 0.13), rgba(112, 241, 181, 0.1)), var(--panel);
}
.handoff code {
display: block;
overflow-x: auto;
border: 1px solid rgba(46, 232, 255, 0.28);
border-radius: 6px;
padding: 1rem;
background: rgba(7, 12, 24, 0.72);
color: var(--cyan-soft);
}
.handoff button {
width: fit-content;
}
.balance-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 8px;
}
table {
width: 100%;
min-width: 820px;
border-collapse: collapse;
background: rgba(20, 33, 61, 0.78);
}
th,
td {
padding: 0.8rem 0.9rem;
border-bottom: 1px solid rgba(157, 243, 255, 0.12);
text-align: left;
vertical-align: top;
}
th {
color: var(--cyan);
font-size: 0.75rem;
text-transform: uppercase;
}
td {
color: var(--muted);
}
.review-list {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.review-list li {
display: grid;
gap: 0.4rem;
padding: 0.85rem;
border: 1px solid rgba(157, 243, 255, 0.14);
background: rgba(7, 12, 24, 0.28);
}
.review-list a {
color: var(--text);
font-weight: 700;
text-decoration: none;
}
.review-list span {
color: var(--muted);
}
.labels {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.labels span,
.status {
width: fit-content;
border: 1px solid rgba(46, 232, 255, 0.25);
border-radius: 999px;
padding: 0.18rem 0.5rem;
background: rgba(46, 232, 255, 0.09);
color: var(--cyan-soft);
font-size: 0.78rem;
}
.status.published {
border-color: rgba(112, 241, 181, 0.38);
color: var(--green);
}
.status.delisted {
border-color: rgba(255, 124, 154, 0.4);
color: var(--red);
}
.panel-error,
.empty-state {
border: 1px solid rgba(255, 124, 154, 0.34);
border-radius: 8px;
padding: 1rem;
background: rgba(255, 124, 154, 0.1);
color: #ffdbe4;
}
.panel-error strong,
.panel-error span {
display: block;
}
.panel-error span {
margin-top: 0.3rem;
color: #ffdbe4;
}
.empty-state {
border-color: var(--line);
background: rgba(32, 52, 86, 0.58);
color: var(--muted);
}
.login-page {
display: grid;
min-height: 100vh;
place-items: center;
padding: 1rem;
}
.login-panel {
display: grid;
gap: 1.5rem;
width: min(100%, 420px);
padding: 1.5rem;
}
.login-form {
display: grid;
gap: 0.75rem;
}
.login-form label {
color: var(--cyan);
font-weight: 800;
}
.form-error {
margin: 0;
color: var(--red);
}
.back-link {
width: fit-content;
}
@media (max-width: 820px) {
.topbar {
position: static;
grid-template-columns: 1fr;
}
.nav {
justify-content: flex-start;
}
.operator-picker,
.search-bar,
.two-column {
grid-template-columns: 1fr;
}
.metrics.wide {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.balance-strip {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 480px) {
.main-grid {
width: min(100% - 1rem, 1180px);
padding-top: 1rem;
}
.topbar {
padding: 0.75rem;
}
.nav a {
flex: 1 1 auto;
text-align: center;
}
.metrics,
.metrics.wide {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
},
server: {
proxy: {
'/api': 'http://127.0.0.1:8000',
'/health': 'http://127.0.0.1:8000',
},
},
});