diff --git a/backend/db.py b/backend/db.py index 68883ba..7603d15 100644 --- a/backend/db.py +++ b/backend/db.py @@ -43,6 +43,11 @@ def init_db() -> None: with _conn() as c: c.executescript(_SCHEMA) + # migration: score_json snapshot column (added 2026-10-13, iter 4) + try: + c.execute("ALTER TABLE results ADD COLUMN score_json TEXT") + except sqlite3.OperationalError: + pass # column already exists def new_result_id() -> str: @@ -59,15 +64,16 @@ def save_result(result_id: str, score: int, percentile: float, bank_version: str, identity: str | None, completed_at: str, payload: str, signature: str, answers: dict, - flagged: bool = False) -> None: + flagged: bool = False, score_json: str | None = None) -> None: with _conn() as c: c.execute( "INSERT INTO results (result_id, score, percentile, bank_version," - " identity, completed_at, payload, signature, answers, flagged)" - " VALUES (?,?,?,?,?,?,?,?,?,?)", + " identity, completed_at, payload, signature, answers, flagged," + " score_json)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?)", (result_id, score, percentile, bank_version, identity, completed_at, payload, signature, json.dumps(answers), - int(flagged)), + int(flagged), score_json), ) @@ -89,4 +95,20 @@ with _conn() as c: rows = c.execute( "SELECT kind, COUNT(*) AS n FROM events GROUP BY kind").fetchall() - return {r["kind"]: r["n"] for r in rows} \ No newline at end of file + return {r["kind"]: r["n"] for r in rows} + + +def stats_overview() -> dict: + """Aggregate numbers for the /stats dashboard.""" + with _conn() as c: + events = {r["kind"]: r["n"] for r in c.execute( + "SELECT kind, COUNT(*) AS n FROM events GROUP BY kind").fetchall()} + total, flagged = c.execute( + "SELECT COUNT(*), COALESCE(SUM(flagged), 0) FROM results").fetchone() + avg_iq = c.execute("SELECT AVG(score) FROM results").fetchone()[0] + return { + "events": events, + "results_total": total, + "results_flagged": flagged, + "avg_iq": round(avg_iq, 1) if avg_iq is not None else None, + } \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index a23b6c4..7b07ee2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -226,6 +226,7 @@ signature=signature, answers=answers, flagged=flagged, + score_json=json.dumps(score), ) db.log_event("finish", {"flagged": flagged, "iq": score["iq"]}) return FinishResponse(result_id=result_id, score=score) @@ -238,6 +239,12 @@ raise HTTPException(404, "Result not found") valid = signing.verify_payload(row["payload"], row["signature"]) payload = json.loads(row["payload"]) + snapshot = {} + if row.get("score_json"): + try: + snapshot = json.loads(row["score_json"]) + except Exception: + snapshot = {} return JSONResponse({ "result_id": result_id, "iq": row["score"], @@ -249,12 +256,25 @@ "signature": row["signature"], "payload": payload, "flagged": bool(row["flagged"]), + "categories": snapshot.get("categories", {}), + "calibrated": snapshot.get("calibrated"), + "total_time_s": snapshot.get("total_time_s"), }) +@app.get("/stats") +def stats_page(): + return FileResponse(FRONTEND_DIR / "stats.html") + + +@app.get("/how") +def how_page(): + return FileResponse(FRONTEND_DIR / "how.html") + + @app.get("/api/stats") def api_stats(): - return db.event_stats() + return JSONResponse(db.stats_overview()) # --- identity binding (phase 2 scaffolding) ----------------------------------- diff --git a/frontend/assets/favicon.svg b/frontend/assets/favicon.svg new file mode 100644 index 0000000..7da5c3c --- /dev/null +++ b/frontend/assets/favicon.svg @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/frontend/how.html b/frontend/how.html new file mode 100644 index 0000000..0d8b612 --- /dev/null +++ b/frontend/how.html @@ -0,0 +1,105 @@ + + +
+ + +For everyone who has ever seen a suspicious IQ screenshot. Here is the actual mechanism — no hand-waving.
++ Every other IQ test hands you an image. An image is just pixels — + open any editor, change "89" to "139", done. There is no way for a + stranger to tell a real certificate from an edited one, because there + is nothing to check it against. +
++ When you finish the test, our server computes your score and signs it + with an Ed25519 private key that never leaves the server. The + signature covers the exact payload: result ID, score, percentile, + question bank version, completion time. Change one character of the + score and the signature no longer matches. +
++ Ed25519 is the same signature scheme used by SSH and TLS-adjacent + tooling. Forging a signature without the private key is not "hard" — + it is computationally infeasible with known methods. +
++ When someone opens your link /v/<id>, + the server re-checks the signature against the stored payload on every + view and shows the verdict. The service public key is published at + /.well-known/veriq-pubkey.txt — + anyone can independently verify a payload+signature pair offline with + any Ed25519 implementation. We don't ask you to trust our UI; the + math is checkable by hand. +
++ Honest limits: a signature proves the result was issued by this + service and unaltered. It does not prove who sat behind the + keyboard — unless the result is claimed via X/Instagram binding, and + even then it proves the account owner took it, not that they didn't + get help. Scores are preliminary-calibrated until 500 completions. + We say this on every result page. Tests that don't state their + calibration status are selling confidence, not measurement. +
++ Take any result link, fetch its payload and signature from + /api/result/<id>, and check the Ed25519 + signature against our public key. Ten lines of Python: +
+
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+import base64, json, urllib.request
+d = json.load(urllib.request.urlopen("https://YOUR-DOMAIN/api/result/RESULT_ID"))
+pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(
+ urllib.request.urlopen("https://YOUR-DOMAIN/.well-known/veriq-pubkey.txt").read()))
+pub.verify(base64.b64decode(d["signature"]),
+ json.dumps(d["payload"], sort_keys=True, separators=(",", ":")).encode())
+print("signature valid") # raises InvalidSignature if tampered
+
Get your own signed result — free, no registration.
+ Start the test → +No third-party trackers — everything below comes from our own event log.
+loading…
+