"""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, reference_docs _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) templates.env.globals["policy_slug"] = reference_docs.policy_slug_from_id templates.env.globals["extension_slug"] = reference_docs.extension_slug_from_id _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(...), repo_hub: str = Form(...), repo_hub_uri: str = Form(...), repo_id: int = Form(...), repo_name: str = Form(...), base_phase_id: str = Form(""), initial_target_amount: float = Form(...), currency: str = Form(...), future_license: str = Form(...), degeneration_policy: str = Form(...), longstop_at: str = Form(...), ): manifest = { "framework": "TRF-0.1", "license": "TRSL-0.1", "phase": { "id": phase_id, "milestone_release": { "name": milestone_release_name, "source_revision": source_revision, "repo_hub": repo_hub, "repo_hub_uri": repo_hub_uri, "repo_id": repo_id, "repo_name": repo_name, }, "initial_target": {"amount": initial_target_amount, "currency": currency}, "future_license": future_license, "degeneration_policy": degeneration_policy, "longstop_at": longstop_at, # Auto-computed, never hand-typed (WP-0012-T04) -- this Trust # Service instance is always the ledger's host in Stage 0. "ledger": f"/phases/{phase_id}/ledger", }, } if base_phase_id: manifest["phase"]["base_phase_id"] = base_phase_id 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)) # --- Reference docs (specs/policies/, specs/profiles/) ---------------------- @app.get("/reference/{kind}/{slug}") def reference_doc( kind: str, slug: str, request: Request, licensor: registry.Licensor = Depends(require_login), ): """Read-only rendering of a `specs/policies/`/`specs/profiles/` markdown file (WP-0015-T03) — never editable from here.""" result = reference_docs.load_reference_doc(kind, slug) if result is None: raise HTTPException(status_code=404, detail="reference document not found") doc_html, frontmatter = result return templates.TemplateResponse( request, "reference.html", _template_context( request, licensor, doc_html=doc_html, doc_title=frontmatter.get("title", slug), source_path=f"specs/{kind}/{slug}.md", ), )