diff --git a/backend/app.py b/backend/app.py index 2ae82ff..88a2b60 100644 --- a/backend/app.py +++ b/backend/app.py @@ -784,6 +784,130 @@ def newserv_account_lock_heartbeat(): +@app.post("/api/newserv/account-locks/session-end") +def newserv_account_lock_session_end(): + auth_error = require_newserv_shared_secret() + if auth_error: + return auth_error + + data = json_body() + + account_id = clean_lock_account_id(data.get("account_id") or data.get("account_id_str")) + source = clean_lock_text(data.get("source"), 64) + session_nonce = clean_lock_text(data.get("session_nonce"), 160) + version = clean_lock_text(data.get("version"), 64) + + if not account_id: + return jsonify({"ok": False, "message": "missing account_id"}), 400 + if not source: + return jsonify({"ok": False, "message": "missing source"}), 400 + if not session_nonce: + return jsonify({"ok": False, "message": "missing session_nonce"}), 400 + + now = utcnow() + expires_at = account_lock_expires_at() + + with connect() as conn: + with conn.transaction(): + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT account_id, holder_source, source_region, source_ship, + account_store, state, sessions, created_at, updated_at, expires_at + FROM account_session_locks + WHERE account_id = %s + FOR UPDATE + """, (account_id,)) + row = cur.fetchone() + + if not row: + return jsonify({ + "ok": True, + "account_id": account_id_str(account_id), + "source": source, + "session_nonce": session_nonce, + "version": version, + "removed": False, + "state": "unlocked", + "remaining_sessions": 0, + "message": "no active lock", + }) + + if row["expires_at"] and row["expires_at"] <= now: + cur.execute(""" + DELETE FROM account_session_locks + WHERE account_id = %s + """, (account_id,)) + return jsonify({ + "ok": True, + "account_id": account_id_str(account_id), + "source": source, + "session_nonce": session_nonce, + "version": version, + "removed": False, + "state": "expired", + "remaining_sessions": 0, + "message": "expired lock removed", + }) + + if row["holder_source"] != source: + return jsonify({ + "ok": False, + "account_id": account_id_str(account_id), + "source": source, + "session_nonce": session_nonce, + "holder_source": row["holder_source"], + "state": row["state"], + "message": "session source does not hold this lock", + "lock": lock_row_payload(row), + }), 409 + + sessions = row["sessions"] or {} + if isinstance(sessions, str): + sessions = json.loads(sessions) + + removed = session_nonce in sessions + if removed: + sessions.pop(session_nonce, None) + + if sessions: + cur.execute(""" + UPDATE account_session_locks + SET state = 'active', + sessions = %s, + updated_at = now(), + expires_at = %s + WHERE account_id = %s + RETURNING account_id, holder_source, source_region, source_ship, + account_store, state, sessions, created_at, updated_at, expires_at + """, (Jsonb(sessions), expires_at, account_id)) + row = cur.fetchone() + else: + cur.execute(""" + UPDATE account_session_locks + SET state = 'draining', + sessions = '{}'::jsonb, + updated_at = now(), + expires_at = %s + WHERE account_id = %s + RETURNING account_id, holder_source, source_region, source_ship, + account_store, state, sessions, created_at, updated_at, expires_at + """, (expires_at, account_id)) + row = cur.fetchone() + + return jsonify({ + "ok": True, + "account_id": account_id_str(account_id), + "source": source, + "session_nonce": session_nonce, + "version": version, + "removed": removed, + "state": row["state"], + "remaining_sessions": len(row["sessions"] or {}), + "lock": lock_row_payload(row), + }) + + + @app.get("/api/newserv/account-locks/") def newserv_account_lock_status(account_id): auth_error = require_newserv_shared_secret()