diff --git a/backend/db.py b/backend/db.py index 3a4c9a4..636ee61 100644 --- a/backend/db.py +++ b/backend/db.py @@ -49,6 +49,11 @@ c.execute("ALTER TABLE results ADD COLUMN score_json TEXT") except sqlite3.OperationalError: pass # column already exists + # migration: superseded flag (auth-before-test flow, 2026-10-13) + try: + c.execute("ALTER TABLE results ADD COLUMN superseded INTEGER DEFAULT 0") + except sqlite3.OperationalError: + pass # column already exists def new_result_id() -> str: @@ -86,6 +91,15 @@ return dict(row) +def supersede_identity(identity: str) -> int: + """Mark all previous active results of this identity as superseded.""" + with _conn() as c: + cur = c.execute( + "UPDATE results SET superseded=1 WHERE identity=? AND superseded=0", + (identity,)) + return cur.rowcount + + def log_event(kind: str, meta: Optional[dict] = None) -> None: with _conn() as c: c.execute("INSERT INTO events (ts, kind, meta) VALUES (?,?,?)", diff --git a/backend/main.py b/backend/main.py index e2eaec3..5b4ad2f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -85,8 +85,18 @@ return FileResponse(FRONTEND_DIR / "index.html") +def _identity_from(request: Request) -> Optional[dict]: + return oauth.verify_identity(request.cookies.get(oauth.IDENTITY_COOKIE, "")) + + @app.get("/test") -def test_page(): +def test_page(request: Request, anon: int = 0): + """Auth-before-test: no valid identity cookie -> redirect to X login. + ?anon=1 explicitly opts out (anonymous result, not bound to identity).""" + if not anon and _identity_from(request) is None: + url = oauth.x_authorize_url() + if url is not None: + return RedirectResponse(url[0], status_code=303) return FileResponse(FRONTEND_DIR / "test.html") @@ -204,6 +214,14 @@ flagged = scoring.sanity_check(answers, task_ids, req.total_time_s) score = scoring.score_test(answers, task_ids, req.total_time_s, flagged) + # identity comes from the login cookie (auth-before-test); a new result + # supersedes all previous active results of the same identity + ident = _identity_from(request) + identity = None + if ident: + identity = ident["provider"] + ":@" + ident["username"] + db.supersede_identity(identity) + result_id = db.new_result_id() completed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) payload = signing.canonical_payload( @@ -211,7 +229,7 @@ score=score["iq"], percentile=score["percentile"], bank_version=scoring.bank_version(), - identity=None, # phase 2: identity binding + identity=identity, completed_at=completed_at, ) signature = signing.sign_payload(payload) @@ -221,7 +239,7 @@ score=score["iq"], percentile=score["percentile"], bank_version=scoring.bank_version(), - identity=None, + identity=identity, completed_at=completed_at, payload=payload, signature=signature, @@ -257,6 +275,7 @@ "signature": row["signature"], "payload": payload, "flagged": bool(row["flagged"]), + "superseded": bool(row.get("superseded", 0)), "categories": snapshot.get("categories", {}), "calibrated": snapshot.get("calibrated"), "total_time_s": snapshot.get("total_time_s"), @@ -273,6 +292,18 @@ return FileResponse(FRONTEND_DIR / "how.html") +@app.get("/api/me") +def api_me(request: Request): + ident = _identity_from(request) + if not ident: + return JSONResponse({"identity": None}) + return JSONResponse({ + "identity": ident["provider"] + ":@" + ident["username"], + "username": ident["username"], + "provider": ident["provider"], + }) + + @app.get("/api/stats") def api_stats(): return JSONResponse(db.stats_overview()) @@ -290,21 +321,21 @@ '

Claim failed

' '

' + provider + ' did not return a usable profile. Your result is unchanged ' 'and still verifiable. You can try again from the result page.

' - '

Back to result

' + '

Back to home

' '' ) return HTMLResponse(body, status_code=502) @app.get("/auth/x/start") -def auth_x_start(result_id: str): - """Begin X OAuth binding for a result. 503 until apps registered.""" +def auth_x_start(): + """Begin X OAuth login. 503 until apps registered.""" if not oauth.x_configured(): raise HTTPException(503, "X binding not configured yet") - url = oauth.x_authorize_url(result_id) + url = oauth.x_authorize_url() if url is None: raise HTTPException(503, "X binding not configured yet") - return JSONResponse({"authorize_url": url[0]}) + return RedirectResponse(url[0], status_code=303) @app.get("/auth/x/callback") @@ -319,12 +350,13 @@ tok = oauth.x_exchange(code, flow["verifier"]) user = oauth.x_userinfo(tok.get("access_token")) if tok else None if not user or not user.get("username"): - return _claim_error_page(flow["result_id"], "X") - identity = "x:@" + user["username"] - db.claim_result(flow["result_id"], identity) - db.log_event("claim", {"result_id": flow["result_id"], "provider": "x", - "user_id": user.get("id"), "username": user["username"]}) - return RedirectResponse("/v/" + flow["result_id"], status_code=303) + return _claim_error_page("", "X") + token = oauth.sign_identity("x", str(user.get("id", "")), user["username"]) + db.log_event("login", {"provider": "x", "username": user["username"]}) + resp = RedirectResponse("/test", status_code=303) + resp.set_cookie(oauth.IDENTITY_COOKIE, token, max_age=oauth.IDENTITY_TTL_S, + httponly=True, samesite="lax", secure=True) + return resp @app.get("/auth/ig/start") diff --git a/backend/oauth.py b/backend/oauth.py index 6e77c9b..04ab9ee 100644 --- a/backend/oauth.py +++ b/backend/oauth.py @@ -70,7 +70,7 @@ return {"state": state, "challenge": challenge, "verifier": verifier} -def x_authorize_url(result_id: str) -> Tuple[str, Optional[str]]: +def x_authorize_url(result_id: str = "") -> Tuple[str, Optional[str]]: """Returns (authorize_url, state) or None if not configured.""" cid = _env("X_CLIENT_ID") if not cid: @@ -192,3 +192,37 @@ if r.status_code != 200: return None return r.json() + + +# --- identity cookie (auth-before-test flow) ----------------------------------- + +IDENTITY_COOKIE = "veriq_id" +IDENTITY_TTL_S = 7 * 24 * 3600 + + +def sign_identity(provider: str, user_id: str, username: str) -> str: + """Stateless Ed25519-signed identity token for the veriq_id cookie.""" + from . import signing + expires = int(time.time()) + IDENTITY_TTL_S + body = "v1|%s|%s|%s|%d" % (provider, user_id, username, expires) + return body + "|" + signing.sign_payload(body) + + +def verify_identity(token: str) -> Optional[dict]: + """Returns {provider, user_id, username} or None (bad signature/expired).""" + from . import signing + if not token: + return None + parts = token.split("|") + if len(parts) != 6 or parts[0] != "v1": + return None + body = "|".join(parts[:5]) + if not signing.verify_payload(body, parts[5]): + return None + try: + expires = int(parts[4]) + except ValueError: + return None + if time.time() > expires: + return None + return {"provider": parts[1], "user_id": parts[2], "username": parts[3]} diff --git a/frontend/assets/result.js b/frontend/assets/result.js index b09f8d7..d15d250 100644 --- a/frontend/assets/result.js +++ b/frontend/assets/result.js @@ -48,10 +48,16 @@ badges.appendChild(b3); if (d.identity) { - $("claimHint").textContent = "This result is already claimed by " + - d.identity + "."; - $("claimX").style.display = "none"; - $("claimIg").style.display = "none"; + var bi = document.createElement("span"); + bi.className = "badge ok"; + bi.textContent = "identity: " + d.identity; + badges.appendChild(bi); + } + if (d.superseded) { + var bs = document.createElement("span"); + bs.className = "badge warn"; + bs.textContent = "superseded by a newer result"; + badges.appendChild(bs); } var cats = $("cats"); @@ -91,39 +97,6 @@ setTimeout(function () { $("copyBtn").textContent = "Copy"; }, 1500); }); - // claim buttons: ask backend for authorize_url, redirect; 503 -> hint - function wireClaim(btnId, provider) { - var btn = $(btnId); - if (!btn) return; - btn.addEventListener("click", function () { - btn.disabled = true; - fetch("/auth/" + provider + "/start?result_id=" + rid) - .then(function (r) { - if (r.status === 503) { - throw new Error("not_configured"); - } - if (!r.ok) throw new Error("http " + r.status); - return r.json(); - }) - .then(function (data) { - window.location.href = data.authorize_url; - }) - .catch(function (e) { - btn.disabled = false; - var err = $("claimErr"); - err.style.display = ""; - if (e.message === "not_configured") { - err.textContent = "Identity binding is being set up — " + - "the service owner must register the X/Instagram apps first. " + - "Your result is already signed and verifiable without it."; - } else { - err.textContent = "Claim failed: " + e.message; - } - }); - }); - } - wireClaim("claimX", "x"); - wireClaim("claimIg", "ig"); var msg = "My VerIQ score: " + d.iq + " (higher than " + pct + "%). Verify it: " + link; diff --git a/frontend/assets/test.js b/frontend/assets/test.js index a6abd9c..a3d1290 100644 --- a/frontend/assets/test.js +++ b/frontend/assets/test.js @@ -2,6 +2,10 @@ (function () { "use strict"; + fetch("/api/me").then(function (r) { return r.json(); }).then(function (d) { + if (d && d.identity) window.__veriqMe = d.identity; + }).catch(function () {}); + var state = { token: null, tasks: [], @@ -162,7 +166,7 @@ state.startedAt = saved.startedAt; state.answers = saved.answers || {}; state.current = saved.current || 0; - $("sessionInfo").textContent = "session restored"; + $("sessionInfo").textContent = (window.__veriqMe ? "as " + window.__veriqMe + " · " : "") + "session restored"; show("testArea"); renderTask(); updateAnswered(); @@ -187,7 +191,7 @@ answers: {}, current: 0 })); } catch (e) {} - $("sessionInfo").textContent = "40 tasks · one attempt"; + $("sessionInfo").textContent = (window.__veriqMe ? "as " + window.__veriqMe + " · " : "") + "40 tasks · one attempt"; show("testArea"); renderTask(); updateAnswered(); diff --git a/frontend/assets/verify.js b/frontend/assets/verify.js index 55615ac..91edc68 100644 --- a/frontend/assets/verify.js +++ b/frontend/assets/verify.js @@ -41,27 +41,12 @@ bn.className = "badge"; bn.textContent = "anonymous result"; badges.appendChild(bn); - var cs = $("claimSection"); - if (cs) cs.style.display = ""; - var cb = $("claimX"); - if (cb) cb.addEventListener("click", function () { - cb.disabled = true; - fetch("/auth/x/start?result_id=" + rid) - .then(function (r) { - if (r.status === 503) throw new Error("not_configured"); - if (!r.ok) throw new Error("http " + r.status); - return r.json(); - }) - .then(function (data) { window.location.href = data.authorize_url; }) - .catch(function (e) { - cb.disabled = false; - var err = $("claimErr"); - err.style.display = ""; - err.textContent = e.message === "not_configured" - ? "Identity binding is being set up — try again later." - : "Claim failed: " + e.message; - }); - }); + } + if (d.superseded) { + var bs = document.createElement("span"); + bs.className = "badge warn"; + bs.textContent = "superseded by a newer result"; + badges.appendChild(bs); } $("kvId").textContent = d.result_id; diff --git a/frontend/how.html b/frontend/how.html index 0d8b612..f74cea7 100644 --- a/frontend/how.html +++ b/frontend/how.html @@ -63,8 +63,9 @@

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 + keyboard — unless the result is bound to an X account before + the test starts (identity is part of the signed payload), 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. diff --git a/frontend/index.html b/frontend/index.html index 3b08bd4..0944fc7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -22,11 +22,16 @@

Your IQ score,
backed by a signature — not a screenshot.

Every IQ test on the internet gives you an image anyone can fake. - VerIQ signs your result with a cryptographic key and gives you a public - link anyone can verify — no login, no trust required. + VerIQ signs your result with a cryptographic key and binds it to your + X account before the test starts — so nobody can cherry-pick + their best attempt or claim someone else's score. The public link + anyone can verify.

Start the test → -

40 tasks · ~25 minutes · free · no registration

+

+ or take it anonymously — no login +

+

40 tasks · ~25 minutes · free

diff --git a/frontend/result.html b/frontend/result.html index 58fdd81..b639e69 100644 --- a/frontend/result.html +++ b/frontend/result.html @@ -41,19 +41,6 @@
-
-

Claim this result

-

- Bind this result to your X or Instagram account — the verify page will - then show your handle next to the score. Nobody will be able to steal - your result by copying the link. -

-
- - -
- -
diff --git a/frontend/verify.html b/frontend/verify.html index f07968d..7d3cc1c 100644 --- a/frontend/verify.html +++ b/frontend/verify.html @@ -23,16 +23,6 @@
-

Verification details