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 @@ '
' + provider + ' did not return a usable profile. Your result is unchanged ' 'and still verifiable. You can try again from the result page.
' - '' + '' '