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 @@ + + + + + +VerIQ — how verification works + + + + +
+
+ + how verification works +
+ +
+

Why a VerIQ link can't be faked.

+

For everyone who has ever seen a suspicious IQ screenshot. Here is the actual mechanism — no hand-waving.

+
+ +
+

1 · The problem with screenshots

+

+ 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. +

+
+ +
+

2 · What VerIQ does instead

+

+ 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. +

+
+ +
+

3 · What a verifier actually checks

+

+ 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. +

+
+ +
+

4 · What this does NOT prove

+

+ 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. +

+
+ +
+

5 · Verify it yourself, right now

+

+ 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 +

+
+ +
+

Try it

+

Get your own signed result — free, no registration.

+ Start the test → +
+ + +
+ + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index b70cb55..3b08bd4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@ + @@ -45,7 +46,7 @@ diff --git a/frontend/result.html b/frontend/result.html index 1ab2834..58fdd81 100644 --- a/frontend/result.html +++ b/frontend/result.html @@ -4,6 +4,7 @@ VerIQ — your result + diff --git a/frontend/stats.html b/frontend/stats.html new file mode 100644 index 0000000..d3434af --- /dev/null +++ b/frontend/stats.html @@ -0,0 +1,58 @@ + + + + + +VerIQ — live stats + + + + +
+
+ + live stats +
+ +
+

The test in numbers.

+

No third-party trackers — everything below comes from our own event log.

+
+ +
+
–tests started
+
–signed results issued
+
–average IQ so far
+
+ +
+

Integrity

+

loading…

+
+ + +
+ + + \ No newline at end of file diff --git a/frontend/test.html b/frontend/test.html index dd9b86b..1ce9d76 100644 --- a/frontend/test.html +++ b/frontend/test.html @@ -4,6 +4,7 @@ VerIQ — test + diff --git a/frontend/verify.html b/frontend/verify.html index 292960e..eeb8fe9 100644 --- a/frontend/verify.html +++ b/frontend/verify.html @@ -4,6 +4,7 @@ VerIQ — verify result + @@ -49,7 +50,7 @@