"""FastAPI surface for the hosted Phase Registry, Extension Registry (WP-0006-T03), and Target Ledger append API (WP-0006-T04). Metrics (T05) and Conversion Attestation (T06) are separate components per `specs/TechnicalSpecificationDocument.md` §4.1 and are not implemented in this module. Every route delegates to `target_revenue.registry` / `target_revenue.ledger`; this file's only job is HTTP framing (status codes, request/response shape, bearer-token extraction), not conformance or chaining logic. """ from __future__ import annotations import os from typing import Any from fastapi import Depends, FastAPI, HTTPException, Request from psycopg import Connection from psycopg_pool import ConnectionPool from .. import attestation, ledger, metrics, registry from . import keys app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0") _DATABASE_URL_ENV = "TRF_DATABASE_URL" 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_licensor(request: Request, conn: Connection = Depends(get_connection)) -> registry.Licensor: auth = request.headers.get("authorization", "") if not auth.lower().startswith("bearer "): raise HTTPException(status_code=401, detail="missing bearer token") token = auth.split(" ", 1)[1].strip() try: return registry.authenticate(conn, token) except registry.RegistrationError as exc: raise HTTPException(status_code=401, detail=str(exc)) from exc @app.post("/phases", status_code=201) def register_phase( manifest: dict[str, Any], licensor: registry.Licensor = Depends(get_licensor), conn: Connection = Depends(get_connection), ) -> dict[str, str]: try: registry.register_phase_manifest(conn, licensor, manifest) except registry.RegistrationError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc return {"phase_id": manifest["phase"]["id"], "status": "registered"} @app.get("/phases/{phase_id}") def read_phase(phase_id: str, conn: Connection = Depends(get_connection)) -> dict[str, Any]: manifest = registry.get_phase_manifest(conn, phase_id) if manifest is None: raise HTTPException(status_code=404, detail="phase not found") return manifest @app.post("/extensions", status_code=201) def register_extension( extension: dict[str, Any], licensor: registry.Licensor = Depends(get_licensor), conn: Connection = Depends(get_connection), ) -> dict[str, str]: try: registry.register_extension(conn, licensor, extension) except registry.RegistrationError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc return { "extension_id": extension["id"], "version": extension["version"], "status": "registered", } @app.get("/extensions/{extension_id}/{version}") def read_extension( extension_id: str, version: str, conn: Connection = Depends(get_connection) ) -> dict[str, Any]: result = registry.get_extension(conn, extension_id, version) if result is None: raise HTTPException(status_code=404, detail="extension not found") return result @app.get("/public-key") def read_public_key() -> dict[str, str]: """The Ed25519 public key ledger entry signatures verify against. Deliberately unauthenticated: an external verifier must be able to check a signature without trusting anything about this API's own access control (TrustServicePRD §3 point 2, offline verifiability). """ return {"algorithm": "ed25519", "public_key_hex": keys.public_key_hex(get_signing_key())} @app.post("/phases/{phase_id}/ledger", status_code=201) def append_ledger_entry( phase_id: str, entry: dict[str, Any], licensor: registry.Licensor = Depends(get_licensor), conn: Connection = Depends(get_connection), signing_key=Depends(get_signing_key), ) -> dict[str, Any]: try: return ledger.append_entry(conn, licensor, phase_id, entry, signing_key) except registry.RegistrationError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @app.get("/phases/{phase_id}/ledger") def read_ledger( phase_id: str, conn: Connection = Depends(get_connection) ) -> list[dict[str, Any]]: """Public: ledger facts are public per FR-10 (aggregate figures, confidential evidence *references* — not the evidence itself).""" if registry.get_phase_manifest(conn, phase_id) is None: raise HTTPException(status_code=404, detail="phase not found") return ledger.get_ledger(conn, phase_id) @app.get("/phases/{phase_id}/metrics") def read_metrics( phase_id: str, conn: Connection = Depends(get_connection) ) -> dict[str, Any]: """Public, unauthenticated per FR-9/FR-10 — see `target_revenue.metrics` for the fact/calculation/forecast labeling this response preserves.""" 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) return metrics.compute_metrics(manifest, entries, metrics.utcnow()) @app.get("/phases/{phase_id}/attestation") def read_attestation( phase_id: str, conn: Connection = Depends(get_connection), signing_key=Depends(get_signing_key), ) -> dict[str, Any]: """Public, unauthenticated. Publishes (idempotently) the first time a conversion is observed, and simply returns the already-published record on every call after that — never regenerates, never requires this call to have happened for conversion to already be legally true (see target_revenue.attestation's module docstring).""" 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) try: return attestation.publish_attestation(conn, manifest, entries, signing_key) except attestation.NotConvertedError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc