store sessions in the DB

This commit is contained in:
Dobromir Popov
2026-07-07 22:13:12 +03:00
parent dae0719a32
commit a0b37ad1b9
5 changed files with 176 additions and 34 deletions

View File

@@ -8,7 +8,8 @@ regular user.
Mutations are append-only events with unique ids — the same replication
model as ``BillingLedger`` — so accounts and API keys converge across the
tracker hive via gossip, and every dashboard can serve registration/login.
Sessions are deliberately local to each tracker (bearer tokens in memory).
Sessions are local to each tracker and persisted so dashboard cookies survive
tracker restarts.
"""
from __future__ import annotations
@@ -115,6 +116,8 @@ class AccountStore:
"account_id": account_id,
"expires": time.time() + SESSION_TTL,
}
self._dirty = True
self.save_to_db()
return token
def session_account(self, token: str | None) -> dict | None:
@@ -134,7 +137,9 @@ class AccountStore:
if not token:
return
with self._lock:
self._sessions.pop(token, None)
if self._sessions.pop(token, None) is not None:
self._dirty = True
self.save_to_db()
# ---- API keys ----
@@ -271,6 +276,10 @@ class AccountStore:
"CREATE TABLE IF NOT EXISTS account_events "
"(event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, ts REAL NOT NULL)"
)
con.execute(
"CREATE TABLE IF NOT EXISTS account_sessions "
"(token TEXT PRIMARY KEY, account_id TEXT NOT NULL, expires REAL NOT NULL)"
)
con.commit()
con.close()
@@ -279,6 +288,10 @@ class AccountStore:
rows = con.execute(
"SELECT payload FROM account_events ORDER BY ts, event_id"
).fetchall()
session_rows = con.execute(
"SELECT token, account_id, expires FROM account_sessions WHERE expires >= ?",
(time.time(),),
).fetchall()
con.close()
with self._lock:
for (payload,) in rows:
@@ -288,6 +301,11 @@ class AccountStore:
continue
if event.get("id") not in self._seen_event_ids:
self._apply_locked(event)
self._sessions = {
token: {"account_id": account_id, "expires": float(expires)}
for token, account_id, expires in session_rows
if account_id in self._accounts
}
self._dirty = False
def save_to_db(self) -> None:
@@ -297,11 +315,21 @@ class AccountStore:
if not self._dirty:
return
events = list(self._event_log)
sessions = [
(token, session["account_id"], float(session["expires"]))
for token, session in self._sessions.items()
if session["expires"] >= time.time()
]
self._dirty = False
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
con.executemany(
"INSERT OR IGNORE INTO account_events (event_id, payload, ts) VALUES (?, ?, ?)",
[(e["id"], json.dumps(e), float(e.get("ts", 0.0))) for e in events],
)
con.execute("DELETE FROM account_sessions")
con.executemany(
"INSERT INTO account_sessions (token, account_id, expires) VALUES (?, ?, ?)",
sessions,
)
con.commit()
con.close()

View File

@@ -224,6 +224,7 @@
<section data-tab="overview"><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section>
<section data-tab="overview" class="wide"><h2>Call wall</h2><div id="call-wall" class="empty">loading...</div></section>
<section data-tab="chat" class="wide chat-section">
<h2 style="display:none">Chat / inference</h2>
<div class="chat-app">
<aside class="chat-sidebar">
<button type="button" class="chat-new-btn" onclick="createNewChatSession()">+ New chat</button>
@@ -273,7 +274,7 @@ async function fetchJson(path) {
const headers = {};
const token = localStorage.getItem("meshnet_session");
if (token) headers["Authorization"] = "Bearer " + token;
const r = await fetch(path, { headers });
const r = await fetch(path, { headers, credentials: "same-origin" });
if (!r.ok) return null;
return await r.json();
} catch { return null; }
@@ -1029,6 +1030,7 @@ async function apiCall(path, method, body, bearerToken) {
const r = await fetch(path, {
method: method || "GET",
headers,
credentials: "same-origin",
body: body ? JSON.stringify(body) : undefined,
});
const data = await r.json().catch(() => ({}));
@@ -1371,7 +1373,7 @@ renderChatModels();
renderChatHistory();
renderChatAuthHint();
setInterval(refresh, 4000);
setInterval(() => { if (sessionToken) renderAccountPanel(); }, 8000);
setInterval(() => { if (sessionToken || isLoggedIn) renderAccountPanel(); }, 8000);
</script>
</body>
</html>

View File

@@ -21,6 +21,7 @@ HTTP API contract:
Response 400/404/503: {"error": str}
"""
import http.cookies
import http.server
import hashlib
import itertools
@@ -47,14 +48,15 @@ from .wallet_proof import binding_message, verify_wallet_signature
from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger
from .calibration import DEFAULT_CALIBRATION_DB_PATH, ToplocCalibrationStore
from .hf_pricing import DEFAULT_HF_PRICING_LOG_DB_PATH, HfPricingLog, refresh_preset_price
from .gossip import NodeGossip
from .logging_setup import tracker_logger
from .model_files import files_for_layer_range, snapshot_dir_for_repo
from .gossip import NodeGossip
from .logging_setup import tracker_logger
from .model_files import files_for_layer_range, snapshot_dir_for_repo
from .raft import RaftNode
_CONSOLE_LIMIT = 300
_PROXY_PROGRESS_LOG_INTERVAL = 5.0
_SESSION_COOKIE_NAME = "meshnet_session"
def _preset_price_keys(name: str, preset: dict) -> set[str]:
@@ -1935,6 +1937,38 @@ def _api_key_from_headers(headers) -> str | None:
return auth.strip() or None
def _session_token_from_headers(headers) -> str | None:
token = _api_key_from_headers(headers)
if token:
return token
cookie_header = headers.get("Cookie")
if not cookie_header:
return None
cookie = http.cookies.SimpleCookie()
try:
cookie.load(cookie_header)
except http.cookies.CookieError:
return None
morsel = cookie.get(_SESSION_COOKIE_NAME)
if morsel is None:
return None
return morsel.value.strip() or None
def _session_cookie_header(token: str | None) -> str:
cookie = http.cookies.SimpleCookie()
cookie[_SESSION_COOKIE_NAME] = token or ""
morsel = cookie[_SESSION_COOKIE_NAME]
morsel["path"] = "/"
morsel["httponly"] = True
morsel["samesite"] = "Lax"
if token:
morsel["max-age"] = str(int(7 * 86400))
else:
morsel["max-age"] = "0"
return morsel.OutputString()
def _usage_total_tokens(payload: dict) -> int | None:
usage = payload.get("usage")
if not isinstance(usage, dict):
@@ -2094,7 +2128,7 @@ def _registration_ban_error(contracts: Any | None, wallet_address: str | None) -
return None
def _tracker_log(
def _tracker_log(
server: "_TrackerHTTPServer",
level: str,
message: str,
@@ -2102,18 +2136,18 @@ def _tracker_log(
stdout: bool = True,
update_console_key: str | None = None,
**fields: Any,
) -> None:
log_level = {
"debug": 10,
"info": 20,
"warn": 30,
"warning": 30,
"error": 40,
}.get(level.lower(), 20)
event = {
"ts": time.time(),
"level": level,
"message": message,
) -> None:
log_level = {
"debug": 10,
"info": 20,
"warn": 30,
"warning": 30,
"error": 40,
}.get(level.lower(), 20)
event = {
"ts": time.time(),
"level": level,
"message": message,
"fields": {
key: value
for key, value in fields.items()
@@ -2134,13 +2168,13 @@ def _tracker_log(
break
if not updated:
server.console_events.append(event)
else:
server.console_events.append(event)
extras = " ".join(f"{key}={value}" for key, value in event["fields"].items())
suffix = f" {extras}" if extras else ""
tracker_logger().log(log_level, f"{message}{suffix}")
if stdout:
print(f"[tracker] {level}: {message}{suffix}", flush=True)
else:
server.console_events.append(event)
extras = " ".join(f"{key}={value}" for key, value in event["fields"].items())
suffix = f" {extras}" if extras else ""
tracker_logger().log(log_level, f"{message}{suffix}")
if stdout:
print(f"[tracker] {level}: {message}{suffix}", flush=True)
@dataclass
@@ -2344,11 +2378,13 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args): # noqa: suppress request logs in tests
pass
def _send_json(self, status: int, data: dict) -> None:
def _send_json(self, status: int, data: dict, headers: dict[str, str] | None = None) -> None:
body = json.dumps(data).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
for name, value in (headers or {}).items():
self.send_header(name, value)
self.end_headers()
try:
self.wfile.write(body)
@@ -2365,7 +2401,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
inference and wallet binding only, never operator endpoints.
"""
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
token = _api_key_from_headers(self.headers)
token = _session_token_from_headers(self.headers)
if not token:
return None, None
if is_validator_token(token, server.validator_service_token):
@@ -4209,7 +4245,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if server.accounts is None:
return None
return server.accounts.session_account(_api_key_from_headers(self.headers))
return server.accounts.session_account(_session_token_from_headers(self.headers))
def _require_accounts(self) -> "AccountStore | None":
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
@@ -4249,7 +4285,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
f"[tracker] account registered: {account.get('email') or account.get('wallet')} "
f"role={account['role']}", flush=True,
)
self._send_json(200, {"account": account, "session_token": token, "api_key": api_key})
self._send_json(
200,
{"account": account, "session_token": token, "api_key": api_key},
headers={"Set-Cookie": _session_cookie_header(token)},
)
def _handle_auth_login(self):
accounts = self._require_accounts()
@@ -4264,14 +4304,18 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._send_json(401, {"error": "invalid credentials"})
return
token = accounts.create_session(account["account_id"])
self._send_json(200, {"account": account, "session_token": token})
self._send_json(
200,
{"account": account, "session_token": token},
headers={"Set-Cookie": _session_cookie_header(token)},
)
def _handle_auth_logout(self):
accounts = self._require_accounts()
if accounts is None:
return
accounts.destroy_session(_api_key_from_headers(self.headers))
self._send_json(200, {"ok": True})
accounts.destroy_session(_session_token_from_headers(self.headers))
self._send_json(200, {"ok": True}, headers={"Set-Cookie": _session_cookie_header(None)})
def _handle_account_me(self):
"""Balance, usage, and API keys for the logged-in account."""