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:
tegwick 2026-07-30 15:43:16 +02:00
parent 5fbae0df02
commit c89b4aa4a5
24 changed files with 3486 additions and 3 deletions

View 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))