Implement WP-0009-T04: Control Plane interactive UI on whynot-design
Builds the Control Plane's browser UI (login, dashboard, Phase registration, Development Credit entry/proposal/review, credential admin, audit log) as a FastAPI + Jinja2 app over the already-finished T03 backend, rather than from scratch — whynot-design's Lit web components are vendored as static assets (source commit 4b62cffc, v0.4.1), with lit itself resolved via an esm.sh CDN import map. Session auth re-checks the credential token against the database on every request rather than trusting the session cookie's cached rights, so a mid-session revocation takes effect immediately. 9 new Docker-gated HTTP-level tests via FastAPI's TestClient (no browser-automation tool available, so real rendering of the <wn-*> components was never visually verified). All four WP-0009 tasks are now done; workplan marked finished. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5fbae0df02
commit
c89b4aa4a5
24 changed files with 3486 additions and 3 deletions
395
src/target_revenue/service/control_plane_app.py
Normal file
395
src/target_revenue/service/control_plane_app.py
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
"""Target Revenue Control Plane — interactive UI (WP-0009-T04).
|
||||
|
||||
An HTML front end over `target_revenue.control_plane`, itself a client
|
||||
layer over the hosted Trust Service (`registry.py`, `ledger.py`) — this
|
||||
module adds no new rights-enforcement or audit logic of its own, it only
|
||||
frames `control_plane.py`'s functions as browser-usable forms and pages
|
||||
(session-based sign-in, flash messages, whynot-design-styled templates).
|
||||
|
||||
Session model: the browser session stores only the raw credential token
|
||||
(`itsdangerous`-signed cookie, via Starlette's `SessionMiddleware` — never
|
||||
the resolved rights tier, which is re-checked against the database on
|
||||
every request so a mid-session revocation takes effect immediately).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from psycopg import Connection
|
||||
from psycopg_pool import ConnectionPool
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .. import control_plane, ledger, metrics, registry
|
||||
from . import keys
|
||||
|
||||
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "control_plane_templates")
|
||||
|
||||
_DATABASE_URL_ENV = "TRF_DATABASE_URL"
|
||||
_SECRET_KEY_ENV = "TRF_CONTROL_PLANE_SECRET_KEY"
|
||||
|
||||
app = FastAPI(title="Target Revenue Control Plane", version="0.1.0")
|
||||
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
|
||||
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
|
||||
|
||||
_secret_key = os.environ.get(_SECRET_KEY_ENV)
|
||||
if not _secret_key:
|
||||
import secrets
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"{_SECRET_KEY_ENV} not set — generating an ephemeral session secret. "
|
||||
"Every restart invalidates existing sessions; do not use this in any "
|
||||
"real deployment.",
|
||||
stacklevel=1,
|
||||
)
|
||||
_secret_key = secrets.token_hex(32)
|
||||
app.add_middleware(SessionMiddleware, secret_key=_secret_key)
|
||||
|
||||
|
||||
def get_pool() -> ConnectionPool:
|
||||
if not hasattr(app.state, "pool"):
|
||||
dsn = os.environ.get(_DATABASE_URL_ENV)
|
||||
if not dsn:
|
||||
raise RuntimeError(f"{_DATABASE_URL_ENV} is not set")
|
||||
app.state.pool = ConnectionPool(dsn, min_size=1, max_size=5, open=True)
|
||||
return app.state.pool
|
||||
|
||||
|
||||
def get_signing_key():
|
||||
if not hasattr(app.state, "signing_key"):
|
||||
app.state.signing_key = keys.load_signing_key()
|
||||
return app.state.signing_key
|
||||
|
||||
|
||||
def get_connection():
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
def get_session_licensor(
|
||||
request: Request, conn: Connection = Depends(get_connection)
|
||||
) -> registry.Licensor | None:
|
||||
"""The signed-in credential, re-authenticated against the database on
|
||||
every request (not trusted from the session cookie alone) so a
|
||||
revocation between requests takes effect immediately."""
|
||||
token = request.session.get("token")
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
return registry.authenticate(conn, token)
|
||||
except registry.RegistrationError:
|
||||
request.session.clear()
|
||||
return None
|
||||
|
||||
|
||||
class _NotAuthenticated(Exception):
|
||||
"""Raised by `require_login` when there is no valid session — handled
|
||||
below by a dedicated exception handler so it never collides with (or
|
||||
overrides the default handling of) ordinary HTTPExceptions like a
|
||||
404 for an unknown Phase id."""
|
||||
|
||||
|
||||
def require_login(
|
||||
licensor: registry.Licensor | None = Depends(get_session_licensor),
|
||||
) -> registry.Licensor:
|
||||
if licensor is None:
|
||||
raise _NotAuthenticated()
|
||||
return licensor
|
||||
|
||||
|
||||
def _template_context(request: Request, licensor: registry.Licensor | None, **extra: Any) -> dict[str, Any]:
|
||||
context = {
|
||||
"request": request,
|
||||
"session_credential_label": licensor.credential_label if licensor else None,
|
||||
"session_rights": licensor.rights if licensor else None,
|
||||
"session_licensor_id": licensor.licensor_id if licensor else None,
|
||||
"flash": request.session.pop("flash", None),
|
||||
"flash_variant": request.session.pop("flash_variant", None),
|
||||
}
|
||||
context.update(extra)
|
||||
return context
|
||||
|
||||
|
||||
def _redirect(url: str, request: Request, flash: str | None = None, variant: str = "info") -> RedirectResponse:
|
||||
if flash:
|
||||
request.session["flash"] = flash
|
||||
request.session["flash_variant"] = variant
|
||||
return RedirectResponse(url=url, status_code=303)
|
||||
|
||||
|
||||
@app.exception_handler(_NotAuthenticated)
|
||||
async def _redirect_to_login(request: Request, exc: _NotAuthenticated):
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
# --- Auth --------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
def login_form(request: Request, licensor: registry.Licensor | None = Depends(get_session_licensor)):
|
||||
if licensor is not None:
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
return templates.TemplateResponse(request, "login.html", _template_context(request, None))
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login_submit(
|
||||
request: Request,
|
||||
token: str = Form(...),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
try:
|
||||
registry.authenticate(conn, token)
|
||||
except registry.RegistrationError:
|
||||
request.session["flash"] = "Invalid or revoked credential token."
|
||||
request.session["flash_variant"] = "danger"
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
request.session["token"] = token
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
# --- Dashboard -----------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def dashboard(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
phases = registry.list_phase_manifests_for_licensor(conn, licensor.licensor_id)
|
||||
return templates.TemplateResponse(
|
||||
request, "dashboard.html", _template_context(request, licensor, phases=phases)
|
||||
)
|
||||
|
||||
|
||||
# --- Phases ----------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/phases/new")
|
||||
def phase_new_form(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
):
|
||||
if not registry.has_right(licensor.rights, "operator"):
|
||||
return _redirect("/", request, "Operator rights required to register a Phase.", "danger")
|
||||
return templates.TemplateResponse(request, "phase_new.html", _template_context(request, licensor))
|
||||
|
||||
|
||||
@app.post("/phases/new")
|
||||
def phase_new_submit(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
phase_id: str = Form(...),
|
||||
milestone_release_name: str = Form(...),
|
||||
source_revision: str = Form(...),
|
||||
initial_target_amount: float = Form(...),
|
||||
currency: str = Form(...),
|
||||
future_license: str = Form(...),
|
||||
degeneration_policy: str = Form(...),
|
||||
longstop_at: str = Form(...),
|
||||
ledger_uri: str = Form(..., alias="ledger"),
|
||||
):
|
||||
manifest = {
|
||||
"framework": "TRF-0.1",
|
||||
"license": "TRSL-0.1",
|
||||
"phase": {
|
||||
"id": phase_id,
|
||||
"milestone_release": {"name": milestone_release_name, "source_revision": source_revision},
|
||||
"initial_target": {"amount": initial_target_amount, "currency": currency},
|
||||
"future_license": future_license,
|
||||
"degeneration_policy": degeneration_policy,
|
||||
"longstop_at": longstop_at,
|
||||
"ledger": ledger_uri,
|
||||
},
|
||||
}
|
||||
try:
|
||||
control_plane.register_phase(conn, licensor, manifest)
|
||||
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
|
||||
return _redirect("/phases/new", request, str(exc), "danger")
|
||||
return _redirect(f"/phases/{phase_id}", request, f"Phase {phase_id} registered.", "success")
|
||||
|
||||
|
||||
@app.get("/phases/{phase_id}")
|
||||
def phase_detail(
|
||||
phase_id: str,
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
manifest = registry.get_phase_manifest(conn, phase_id)
|
||||
if manifest is None:
|
||||
raise HTTPException(status_code=404, detail="phase not found")
|
||||
entries = ledger.get_ledger(conn, phase_id)
|
||||
computed_metrics = metrics.compute_metrics(manifest, entries, metrics.utcnow())
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"phase_detail.html",
|
||||
_template_context(request, licensor, manifest=manifest, ledger=entries, metrics=computed_metrics),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/phases/{phase_id}/ledger")
|
||||
def phase_ledger_submit(
|
||||
phase_id: str,
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
signing_key=Depends(get_signing_key),
|
||||
entry_id: str = Form(...),
|
||||
amount: float = Form(...),
|
||||
currency: str = Form(...),
|
||||
recognized_at: str = Form(...),
|
||||
evidence_reference: str = Form(...),
|
||||
extension_id: str = Form(...),
|
||||
extension_version: str = Form(...),
|
||||
):
|
||||
entry_input = {
|
||||
"id": entry_id,
|
||||
"phase": phase_id,
|
||||
"type": "development-credit",
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"recognized_at": recognized_at,
|
||||
"evidence_reference": evidence_reference,
|
||||
"extension": {"id": extension_id, "version": extension_version},
|
||||
}
|
||||
try:
|
||||
if registry.has_right(licensor.rights, "operator"):
|
||||
control_plane.append_development_credit(conn, licensor, phase_id, entry_input, signing_key)
|
||||
flash = f"Entry {entry_id} appended."
|
||||
else:
|
||||
control_plane.propose_ledger_entry(conn, licensor, phase_id, entry_input)
|
||||
flash = f"Entry {entry_id} submitted for review."
|
||||
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
|
||||
return _redirect(f"/phases/{phase_id}", request, str(exc), "danger")
|
||||
return _redirect(f"/phases/{phase_id}", request, flash, "success")
|
||||
|
||||
|
||||
# --- Proposals (Operator+) --------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/proposals")
|
||||
def proposals_list(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
if not registry.has_right(licensor.rights, "operator"):
|
||||
return _redirect("/", request, "Operator rights required to review proposals.", "danger")
|
||||
proposals = control_plane.list_proposed_entries(conn)
|
||||
return templates.TemplateResponse(
|
||||
request, "proposals.html", _template_context(request, licensor, proposals=proposals)
|
||||
)
|
||||
|
||||
|
||||
@app.post("/proposals/{proposal_id}/approve")
|
||||
def proposal_approve(
|
||||
proposal_id: int,
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
signing_key=Depends(get_signing_key),
|
||||
):
|
||||
try:
|
||||
control_plane.approve_proposed_entry(conn, licensor, proposal_id, signing_key)
|
||||
except control_plane.ControlPlaneError as exc:
|
||||
return _redirect("/proposals", request, str(exc), "danger")
|
||||
return _redirect("/proposals", request, f"Proposal {proposal_id} approved.", "success")
|
||||
|
||||
|
||||
@app.post("/proposals/{proposal_id}/reject")
|
||||
def proposal_reject(
|
||||
proposal_id: int,
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
try:
|
||||
control_plane.reject_proposed_entry(conn, licensor, proposal_id)
|
||||
except control_plane.ControlPlaneError as exc:
|
||||
return _redirect("/proposals", request, str(exc), "danger")
|
||||
return _redirect("/proposals", request, f"Proposal {proposal_id} rejected.", "success")
|
||||
|
||||
|
||||
# --- Credentials (Admin) ----------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/admin/credentials")
|
||||
def admin_credentials_form(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
):
|
||||
if not registry.has_right(licensor.rights, "admin"):
|
||||
return _redirect("/", request, "Admin rights required.", "danger")
|
||||
return templates.TemplateResponse(request, "admin_credentials.html", _template_context(request, licensor))
|
||||
|
||||
|
||||
@app.post("/admin/credentials")
|
||||
def admin_credentials_issue(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
credential_label: str = Form(...),
|
||||
rights: str = Form(...),
|
||||
):
|
||||
if not registry.has_right(licensor.rights, "admin"):
|
||||
return _redirect("/", request, "Admin rights required.", "danger")
|
||||
try:
|
||||
new_credential = control_plane.issue_user_credential(
|
||||
conn, licensor, licensor.licensor_id, credential_label, rights
|
||||
)
|
||||
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
|
||||
return _redirect("/admin/credentials", request, str(exc), "danger")
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin_credentials.html",
|
||||
_template_context(
|
||||
request, licensor,
|
||||
new_token=new_credential.token, new_token_label=new_credential.credential_label,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/credentials/revoke")
|
||||
def admin_credentials_revoke(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
token: str = Form(...),
|
||||
):
|
||||
if not registry.has_right(licensor.rights, "admin"):
|
||||
return _redirect("/", request, "Admin rights required.", "danger")
|
||||
try:
|
||||
control_plane.revoke_user_credential(conn, licensor, token)
|
||||
except control_plane.ControlPlaneError as exc:
|
||||
return _redirect("/admin/credentials", request, str(exc), "danger")
|
||||
return _redirect("/admin/credentials", request, "Credential revoked.", "success")
|
||||
|
||||
|
||||
# --- Audit log ---------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/audit")
|
||||
def audit_log(
|
||||
request: Request,
|
||||
licensor: registry.Licensor = Depends(require_login),
|
||||
conn: Connection = Depends(get_connection),
|
||||
):
|
||||
log = control_plane.get_audit_log(conn)
|
||||
return templates.TemplateResponse(request, "audit.html", _template_context(request, licensor, log=log))
|
||||
Loading…
Add table
Add a link
Reference in a new issue