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>
2026-07-30 15:43:16 +02:00
|
|
|
"""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
|
2026-08-03 23:45:59 +02:00
|
|
|
from . import keys, reference_docs
|
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>
2026-07-30 15:43:16 +02:00
|
|
|
|
|
|
|
|
_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)
|
2026-08-03 23:45:59 +02:00
|
|
|
templates.env.globals["policy_slug"] = reference_docs.policy_slug_from_id
|
|
|
|
|
templates.env.globals["extension_slug"] = reference_docs.extension_slug_from_id
|
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>
2026-07-30 15:43:16 +02:00
|
|
|
|
|
|
|
|
_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(...),
|
2026-08-03 20:43:30 +02:00
|
|
|
repo_hub: str = Form(...),
|
|
|
|
|
repo_hub_uri: str = Form(...),
|
|
|
|
|
repo_id: int = Form(...),
|
|
|
|
|
repo_name: str = Form(...),
|
|
|
|
|
base_phase_id: str = Form(""),
|
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>
2026-07-30 15:43:16 +02:00
|
|
|
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,
|
2026-08-03 20:43:30 +02:00
|
|
|
"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,
|
|
|
|
|
},
|
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>
2026-07-30 15:43:16 +02:00
|
|
|
"initial_target": {"amount": initial_target_amount, "currency": currency},
|
|
|
|
|
"future_license": future_license,
|
|
|
|
|
"degeneration_policy": degeneration_policy,
|
|
|
|
|
"longstop_at": longstop_at,
|
2026-08-03 20:43:30 +02:00
|
|
|
# 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",
|
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>
2026-07-30 15:43:16 +02:00
|
|
|
},
|
|
|
|
|
}
|
2026-08-03 20:43:30 +02:00
|
|
|
if base_phase_id:
|
|
|
|
|
manifest["phase"]["base_phase_id"] = base_phase_id
|
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>
2026-07-30 15:43:16 +02:00
|
|
|
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))
|
2026-08-03 23:45:59 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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",
|
|
|
|
|
),
|
|
|
|
|
)
|