Files
Ten31-Portal/backend/ten31portal/main.py
T
Jonathan Kirkwood 34842cd994 Add startup re-import queue to apply importer fixes to signed rounds
The loan-aware NAV fix (0.2.47) only affects new imports; an already-signed
quarter keeps its old holdings until re-imported. Add a boot-time queue: an
operator stages an eNAV plus a small JSON descriptor under
data/pending_reimports and the app re-imports it on startup via the same code
path as the admin Import screen, then removes the processed files. Guarded so a
queue problem never blocks boot. Bumps the package to 0.2.48.

Used to refresh SATS Credit Fund LP Q4 2025 to its full NAV including the
Fold Holdings loan.
2026-09-02 09:35:54 -05:00

160 lines
6.5 KiB
Python

"""Ten31Portal FastAPI application."""
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import FileResponse
from ten31portal import config
from ten31portal.config import (
INSECURE_SESSION_SECRETS, SESSION_COOKIE_SECURE, SESSION_MAX_AGE, SESSION_SECRET,
)
from ten31portal.db_init import run_migrations
from ten31portal.routers.auth_router import router as auth_router
from ten31portal.routers.audit_router import router as audit_router
from ten31portal.routers.entity_router import router as entity_router
from ten31portal.routers.holding_router import router as holding_router
from ten31portal.routers.position_router import router as position_router
from ten31portal.routers.round_router import router as round_router
from ten31portal.routers.import_router import router as import_router
from ten31portal.routers.user_router import router as user_router
from ten31portal.routers.document_router import router as document_router
from ten31portal.routers.capital_account_router import router as capital_account_router
from ten31portal.routers.capital_import_router import router as capital_import_router
@asynccontextmanager
async def lifespan(app: FastAPI):
# Refuse to boot on a publicly-known session secret: with one, anyone can forge a session
# cookie for any user id and bypass authentication. start.sh generates a real secret; this
# is the backstop for a misconfigured or bypassed entrypoint.
if SESSION_SECRET in INSECURE_SESSION_SECRETS or len(SESSION_SECRET) < 32:
raise RuntimeError(
"TEN31_SESSION_SECRET is unset, a known default, or too short. Set it to a random "
"value of at least 32 characters before starting the portal."
)
run_migrations()
from ten31portal.storage import ensure_docs_dir
ensure_docs_dir()
# Drain any operator-queued eNAV re-imports (e.g. to apply an importer fix to a
# signed round). Guarded so a queue problem can never block the app from booting.
try:
from ten31portal.pending_reimports import process_pending_reimports
process_pending_reimports()
except Exception:
logging.getLogger("ten31portal").exception("pending re-import sweep failed")
yield
# Security headers applied to every response. The SPA loads only same-origin hashed assets and
# renders the TOTP QR as a data: image, so this CSP does not need to loosen script-src.
_SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": (
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; "
"img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; "
"connect-src 'self'; font-src 'self' data:; form-action 'self'"
),
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
}
app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan)
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
for name, value in _SECURITY_HEADERS.items():
response.headers.setdefault(name, value)
return response
app.add_middleware(
SessionMiddleware,
secret_key=SESSION_SECRET,
https_only=SESSION_COOKIE_SECURE,
same_site="lax",
max_age=SESSION_MAX_AGE,
)
# Compress HTML/JS/CSS/JSON over the wire (the 320KB JS bundle gzips to ~80KB). Added after
# SessionMiddleware so it sits outermost and compresses the final response. minimum_size skips
# tiny payloads (health checks, small JSON) where compression isn't worth it.
app.add_middleware(GZipMiddleware, minimum_size=500)
app.include_router(auth_router)
app.include_router(audit_router)
app.include_router(entity_router)
app.include_router(holding_router)
app.include_router(position_router)
app.include_router(round_router)
app.include_router(import_router)
app.include_router(user_router)
app.include_router(document_router)
app.include_router(capital_account_router)
app.include_router(capital_import_router)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
def _contained_static_path(static_root: Path, path: str) -> Path | None:
"""Resolve `path` under `static_root`, returning the file only if it stays
within the root. Percent-encoded traversal (..%2f) survives routing and
would otherwise let an unauthenticated caller read files outside static/
(e.g. the DB or session secret on the data volume). Returns None if the
resolved path escapes the root."""
root = static_root.resolve()
candidate = (root / path).resolve()
if candidate != root and root not in candidate.parents:
return None
return candidate
# Serve built frontend in production (when static/ dir exists next to the app)
_static_dir = Path(__file__).resolve().parent.parent / "static"
if _static_dir.is_dir():
# index.html must always be revalidated so a new build is picked up right after an
# upgrade (its hashed asset references change). Hashed assets themselves are immutable.
_NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"}
# Vite content-hashes asset filenames (index-UPbvqVP1.js), so they can cache forever.
_IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"}
_ONE_DAY = {"Cache-Control": "public, max-age=86400"}
def _index() -> FileResponse:
return FileResponse(_static_dir / "index.html", headers=_NO_CACHE)
@app.api_route("/{path:path}", methods=["GET", "HEAD"])
async def serve_spa(path: str):
file = _contained_static_path(_static_dir, path)
if file is None:
return _index()
if file.is_file():
# Don't let the HTML entrypoint get cached; fingerprinted assets can cache.
if file.name == "index.html":
return _index()
if path.startswith("assets/"):
return FileResponse(file, headers=_IMMUTABLE)
if path in ("ten31-logo.png", "favicon.svg", "favicon.ico"):
return FileResponse(file, headers=_ONE_DAY)
return FileResponse(file)
return _index()
def cli() -> None:
import uvicorn
uvicorn.run("ten31portal.main:app", host="0.0.0.0", port=8000, reload=True)
if __name__ == "__main__":
cli()