"""VerIQ — FastAPI application (v1 MVP).
Standalone public IQ test with signed, verifiable results.
Run: uvicorn backend.main:app --host 127.0.0.1 --port 8801
"""
import json
import os
import secrets
import time
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
from pydantic import BaseModel, Field
from . import db, scoring, signing
app = FastAPI(title="VerIQ", docs_url=None, redoc_url=None)
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
# --- in-memory test sessions (single-process MVP) ---------------------------
# session_token -> {task_ids, started_at, ip}
SESSIONS: dict[str, dict] = {}
SESSION_TTL_S = 60 * 60 # 1 hour to finish after start
# --- simple per-IP rate limiting --------------------------------------------
_RATE: dict[str, list[float]] = {}
RATE_LIMIT_STARTS_PER_HOUR = 10
def _rate_ok(ip: str) -> bool:
now = time.time()
window = _RATE.setdefault(ip, [])
window[:] = [t for t in window if now - t < 3600]
if len(window) >= RATE_LIMIT_STARTS_PER_HOUR:
return False
window.append(now)
return True
def _client_ip(request: Request) -> str:
fwd = request.headers.get("x-forwarded-for")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else "unknown"
# --- models ------------------------------------------------------------------
class StartResponse(BaseModel):
session_token: str
tasks: list[dict]
time_limit_s: int
class FinishRequest(BaseModel):
session_token: str
answers: dict[str, int] = Field(default_factory=dict)
total_time_s: float = 0.0
class FinishResponse(BaseModel):
result_id: str
score: dict
# --- static pages -------------------------------------------------------------
@app.get("/")
def index():
return FileResponse(FRONTEND_DIR / "index.html")
@app.get("/test")
def test_page():
return FileResponse(FRONTEND_DIR / "test.html")
@app.get("/v/{result_id}")
def verify_page(result_id: str):
return FileResponse(FRONTEND_DIR / "verify.html")
@app.get("/assets/{filename}")
def assets(filename: str):
# simple safe-join guard
if "/" in filename or ".." in filename:
raise HTTPException(404)
path = FRONTEND_DIR / "assets" / filename
if not path.exists():
raise HTTPException(404)
return FileResponse(path)
@app.get("/.well-known/veriq-pubkey.txt")
def pubkey():
return PlainTextResponse(signing.public_key_b64() + "\n")
# --- API ----------------------------------------------------------------------
@app.post("/api/start", response_model=StartResponse)
def api_start(request: Request):
ip = _client_ip(request)
if not _rate_ok(ip):
raise HTTPException(429, "Too many attempts, try later")
bank = scoring.load_bank()
all_tasks = bank["tasks"]
# v1: use all tasks, shuffled deterministically per session
ids = [t["id"] for t in all_tasks]
secrets.SystemRandom().shuffle(ids)
tasks = [scoring.task_by_id(tid) for tid in ids]
token = secrets.token_urlsafe(24)
SESSIONS[token] = {
"task_ids": ids,
"started_at": time.time(),
"ip": ip,
}
# prune expired sessions
now = time.time()
for k in [k for k, v in SESSIONS.items() if now - v["started_at"] > SESSION_TTL_S]:
SESSIONS.pop(k, None)
db.log_event("start", {"ip_hash": secrets.token_hex(4)})
# strip answers before sending
public_tasks = [
{k: t[k] for k in ("id", "category", "difficulty", "time_limit_s",
"prompt", "choices")}
for t in tasks
]
total_limit = sum(t.get("time_limit_s", 60) for t in tasks)
return StartResponse(session_token=token, tasks=public_tasks,
time_limit_s=total_limit)
@app.post("/api/finish", response_model=FinishResponse)
def api_finish(req: FinishRequest, request: Request):
sess = SESSIONS.get(req.session_token)
if sess is None:
raise HTTPException(404, "Session not found or expired")
if time.time() - sess["started_at"] > SESSION_TTL_S:
SESSIONS.pop(req.session_token, None)
raise HTTPException(410, "Session expired")
SESSIONS.pop(req.session_token, None)
task_ids = sess["task_ids"]
# ignore answers for unknown tasks
answers = {k: v for k, v in req.answers.items() if scoring.task_by_id(k)}
flagged = scoring.sanity_check(answers, task_ids, req.total_time_s)
score = scoring.score_test(answers, task_ids, req.total_time_s, flagged)
result_id = db.new_result_id()
completed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
payload = signing.canonical_payload(
result_id=result_id,
score=score["iq"],
percentile=score["percentile"],
bank_version=scoring.bank_version(),
identity=None, # phase 2: identity binding
completed_at=completed_at,
)
signature = signing.sign_payload(payload)
db.save_result(
result_id=result_id,
score=score["iq"],
percentile=score["percentile"],
bank_version=scoring.bank_version(),
identity=None,
completed_at=completed_at,
payload=payload,
signature=signature,
answers=answers,
flagged=flagged,
)
db.log_event("finish", {"flagged": flagged, "iq": score["iq"]})
return FinishResponse(result_id=result_id, score=score)
@app.get("/api/result/{result_id}")
def api_result(result_id: str):
row = db.get_result(result_id)
if row is None:
raise HTTPException(404, "Result not found")
valid = signing.verify_payload(row["payload"], row["signature"])
payload = json.loads(row["payload"])
return JSONResponse({
"result_id": result_id,
"iq": row["score"],
"percentile": row["percentile"],
"completed_at": row["completed_at"],
"identity": row["identity"],
"bank_version": row["bank_version"],
"signature_valid": valid,
"signature": row["signature"],
"payload": payload,
"flagged": bool(row["flagged"]),
})
@app.get("/api/stats")
def api_stats():
return db.event_stats()
# --- startup --------------------------------------------------------------------
@app.on_event("startup")
def startup():
db.init_db()
# fail fast if bank is missing/broken
scoring.load_bank()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=int(
os.environ.get("VERIQ_PORT", "8801")))