skills update; USER ACCOUNT system! Alpha!

This commit is contained in:
D.Popov
2026-07-03 19:22:39 +03:00
parent 5179806a67
commit 7caf12980a
14 changed files with 1260 additions and 11 deletions

View File

@@ -0,0 +1,299 @@
"""Tracker user accounts: registration, login, API-key management.
Accounts are identified by email address or wallet address (either works as
the login identifier) and authenticated with a password (PBKDF2-SHA256).
The first account ever registered becomes the admin; everyone after is a
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).
"""
from __future__ import annotations
import hashlib
import json
import re
import secrets
import sqlite3
import threading
import time
import uuid
DEFAULT_ACCOUNTS_DB_PATH = "accounts.sqlite"
SESSION_TTL = 7 * 86400.0 # seconds
PBKDF2_ITERATIONS = 200_000
MIN_PASSWORD_LENGTH = 8
API_KEY_PREFIX = "sk-mesh-"
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _hash_password(password: str, salt: str) -> str:
return hashlib.pbkdf2_hmac(
"sha256", password.encode(), bytes.fromhex(salt), PBKDF2_ITERATIONS
).hex()
class AccountStore:
"""Thread-safe account/API-key store with SQLite persistence and event replication."""
def __init__(self, db_path: str | None = None) -> None:
self._db_path = db_path
self._lock = threading.Lock()
self._accounts: dict[str, dict] = {} # account_id -> record
self._by_identifier: dict[str, str] = {} # lowercased email/wallet -> account_id
self._active_keys: dict[str, str] = {} # api_key -> account_id
self._revoked_keys: set[str] = set()
self._sessions: dict[str, dict] = {} # token -> {account_id, expires}
self._seen_event_ids: set[str] = set()
self._event_log: list[dict] = []
self._dirty = False
if self._db_path:
self._init_db()
self._load_from_db()
# ---- registration & login ----
def register(
self,
*,
email: str | None = None,
wallet: str | None = None,
password: str,
) -> dict:
"""Create an account. The first account becomes the admin.
Raises ValueError with a user-facing message on invalid input.
"""
email = (email or "").strip().lower() or None
wallet = (wallet or "").strip() or None
if not email and not wallet:
raise ValueError("provide an email or a wallet address")
if email and not _EMAIL_RE.match(email):
raise ValueError("invalid email address")
if len(password or "") < MIN_PASSWORD_LENGTH:
raise ValueError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
with self._lock:
for identifier in filter(None, (email, wallet)):
if identifier.lower() in self._by_identifier:
raise ValueError(f"an account for {identifier!r} already exists")
salt = secrets.token_hex(16)
event = {
"id": f"register-{uuid.uuid4().hex}",
"type": "register",
"account_id": f"acct-{uuid.uuid4().hex}",
"email": email,
"wallet": wallet,
"role": "admin" if not self._accounts else "user",
"password_hash": _hash_password(password, salt),
"salt": salt,
"ts": time.time(),
}
self._apply_locked(event)
return self._public_view(self._accounts[event["account_id"]])
def verify_login(self, identifier: str, password: str) -> dict | None:
"""Return the public account view when credentials match, else None."""
with self._lock:
account_id = self._by_identifier.get((identifier or "").strip().lower())
if account_id is None:
return None
record = self._accounts[account_id]
if _hash_password(password or "", record["salt"]) != record["password_hash"]:
return None
return self._public_view(record)
# ---- sessions (local to this tracker) ----
def create_session(self, account_id: str) -> str:
token = secrets.token_urlsafe(32)
with self._lock:
self._sessions[token] = {
"account_id": account_id,
"expires": time.time() + SESSION_TTL,
}
return token
def session_account(self, token: str | None) -> dict | None:
if not token:
return None
with self._lock:
session = self._sessions.get(token)
if session is None:
return None
if session["expires"] < time.time():
del self._sessions[token]
return None
record = self._accounts.get(session["account_id"])
return self._public_view(record) if record else None
def destroy_session(self, token: str | None) -> None:
if not token:
return
with self._lock:
self._sessions.pop(token, None)
# ---- API keys ----
def create_api_key(self, account_id: str) -> str:
api_key = API_KEY_PREFIX + secrets.token_hex(24)
with self._lock:
if account_id not in self._accounts:
raise ValueError("unknown account")
self._apply_locked({
"id": f"createkey-{uuid.uuid4().hex}",
"type": "create_key",
"account_id": account_id,
"api_key": api_key,
"ts": time.time(),
})
return api_key
def revoke_api_key(self, account_id: str, api_key: str) -> bool:
"""Revoke a key owned by ``account_id``. Returns False if not owned."""
with self._lock:
if self._active_keys.get(api_key) != account_id:
return False
self._apply_locked({
"id": f"revokekey-{uuid.uuid4().hex}",
"type": "revoke_key",
"account_id": account_id,
"api_key": api_key,
"ts": time.time(),
})
return True
def keys_for(self, account_id: str) -> list[str]:
with self._lock:
return sorted(
key for key, owner in self._active_keys.items() if owner == account_id
)
def is_key_revoked(self, api_key: str) -> bool:
with self._lock:
return api_key in self._revoked_keys
# ---- views ----
def _public_view(self, record: dict) -> dict:
return {
"account_id": record["account_id"],
"email": record.get("email"),
"wallet": record.get("wallet"),
"role": record["role"],
"created_ts": record.get("ts", 0.0),
}
def get_account(self, account_id: str) -> dict | None:
with self._lock:
record = self._accounts.get(account_id)
return self._public_view(record) if record else None
def list_accounts(self) -> list[dict]:
with self._lock:
views = []
for record in self._accounts.values():
view = self._public_view(record)
view["api_keys"] = sorted(
key for key, owner in self._active_keys.items()
if owner == record["account_id"]
)
views.append(view)
return sorted(views, key=lambda v: v["created_ts"])
def account_count(self) -> int:
with self._lock:
return len(self._accounts)
# ---- replication (same model as BillingLedger) ----
def events_since(self, index: int) -> tuple[list[dict], int]:
with self._lock:
return list(self._event_log[index:]), len(self._event_log)
def apply_events(self, events: list[dict]) -> int:
applied = 0
with self._lock:
for event in events:
event_id = event.get("id")
if not event_id or event_id in self._seen_event_ids:
continue
self._apply_locked(event)
applied += 1
return applied
def _apply_locked(self, event: dict) -> None:
etype = event.get("type")
if etype == "register":
account_id = event["account_id"]
record = {
"account_id": account_id,
"email": event.get("email"),
"wallet": event.get("wallet"),
"role": event.get("role", "user"),
"password_hash": event["password_hash"],
"salt": event["salt"],
"ts": float(event.get("ts", 0.0)),
}
self._accounts[account_id] = record
for identifier in filter(None, (record["email"], record["wallet"])):
self._by_identifier.setdefault(identifier.lower(), account_id)
elif etype == "create_key":
api_key = event["api_key"]
if api_key not in self._revoked_keys:
self._active_keys[api_key] = event["account_id"]
elif etype == "revoke_key":
api_key = event["api_key"]
self._active_keys.pop(api_key, None)
self._revoked_keys.add(api_key)
else:
return
self._seen_event_ids.add(event["id"])
self._event_log.append(event)
self._dirty = True
# ---- persistence ----
def _init_db(self) -> None:
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
con.execute(
"CREATE TABLE IF NOT EXISTS account_events "
"(event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, ts REAL NOT NULL)"
)
con.commit()
con.close()
def _load_from_db(self) -> None:
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
rows = con.execute(
"SELECT payload FROM account_events ORDER BY ts, event_id"
).fetchall()
con.close()
with self._lock:
for (payload,) in rows:
try:
event = json.loads(payload)
except json.JSONDecodeError:
continue
if event.get("id") not in self._seen_event_ids:
self._apply_locked(event)
self._dirty = False
def save_to_db(self) -> None:
if not self._db_path:
return
with self._lock:
if not self._dirty:
return
events = list(self._event_log)
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.commit()
con.close()

View File

@@ -20,6 +20,7 @@ import uuid
DEFAULT_PRICE_PER_1K_TOKENS = 0.02 # USDT
DEFAULT_STARTING_CREDIT = 1.0 # USDT of Caller Credit for a new API key
DEFAULT_BILLING_DB_PATH = "billing.sqlite"
NODE_REVENUE_SHARE = 0.90 # nodes 90% / protocol cut 10% (ADR-0015)
@@ -364,6 +365,34 @@ class BillingLedger:
with self._lock:
return self._node_pending.get(wallet, 0.0)
def usage_for(self, api_keys: list[str], *, recent_limit: int = 20) -> dict:
"""Aggregate charge history for a set of API keys (dashboard view)."""
keys = set(api_keys)
requests = 0
total_tokens = 0
total_cost = 0.0
recent: list[dict] = []
with self._lock:
for event in self._event_log:
if event.get("type") != "charge" or event.get("api_key") not in keys:
continue
requests += 1
total_tokens += int(event.get("total_tokens", 0))
total_cost += float(event.get("cost", 0.0))
recent.append({
"api_key": event["api_key"],
"model": event.get("model"),
"total_tokens": event.get("total_tokens", 0),
"cost": event.get("cost", 0.0),
"ts": event.get("ts", 0.0),
})
return {
"requests": requests,
"total_tokens": total_tokens,
"total_cost": total_cost,
"recent": recent[-recent_limit:],
}
def snapshot(self) -> dict:
with self._lock:
return {

View File

@@ -4,6 +4,8 @@ import argparse
import sys
import time
from .accounts import DEFAULT_ACCOUNTS_DB_PATH
from .billing import DEFAULT_BILLING_DB_PATH
from .server import TrackerServer, derive_relay_url_from_public_tracker_url
@@ -40,9 +42,31 @@ def main() -> None:
)
common.add_argument(
"--billing-db",
default=None,
default=DEFAULT_BILLING_DB_PATH,
metavar="PATH",
help="SQLite database path for the USDT billing ledger (enables billing; ADR-0015)",
help=(
"SQLite database path for the USDT billing ledger "
f"(default: {DEFAULT_BILLING_DB_PATH}; ADR-0015)"
),
)
common.add_argument(
"--no-billing",
action="store_true",
help="Disable the USDT billing ledger",
)
common.add_argument(
"--accounts-db",
default=DEFAULT_ACCOUNTS_DB_PATH,
metavar="PATH",
help=(
"SQLite database path for dashboard user accounts "
f"(default: {DEFAULT_ACCOUNTS_DB_PATH})"
),
)
common.add_argument(
"--no-accounts",
action="store_true",
help="Disable dashboard user accounts (registration/login)",
)
common.add_argument(
"--solana-rpc-url",
@@ -108,7 +132,9 @@ def main() -> None:
cluster_self_url=args.self_url,
stats_db=getattr(args, "stats_db", None),
relay_url=relay_url,
billing_db=getattr(args, "billing_db", None),
enable_billing=not args.no_billing,
billing_db=None if args.no_billing else args.billing_db,
accounts_db=None if args.no_accounts else args.accounts_db,
treasury=treasury,
settle_period=args.settle_period,
payout_threshold=args.payout_threshold,

View File

@@ -30,6 +30,20 @@
.empty { color:var(--dim); font-style:italic; }
.pill { display:inline-block; padding:0 7px; border-radius:9px;
border:1px solid var(--border); font-size:11px; }
input, button { font:inherit; color:var(--fg); background:var(--bg);
border:1px solid var(--border); border-radius:6px; padding:5px 8px; }
input { width:100%; margin-bottom:6px; }
button { cursor:pointer; color:var(--accent); }
button:hover { border-color:var(--accent); }
button.small { font-size:11px; padding:1px 7px; }
.form-row { display:flex; gap:8px; }
.form-row button { white-space:nowrap; }
.error-msg { color:var(--bad); font-size:12px; min-height:16px; }
.keybox { word-break:break-all; background:var(--bg); border:1px solid var(--border);
border-radius:6px; padding:4px 8px; margin:4px 0; font-size:11px; }
.tabs { display:flex; gap:10px; margin-bottom:8px; }
.tabs a { color:var(--dim); cursor:pointer; }
.tabs a.active { color:var(--accent); border-bottom:1px solid var(--accent); }
</style>
</head>
<body>
@@ -39,6 +53,8 @@
<span class="meta" id="refreshed"></span>
</header>
<main>
<section id="account-section"><h2>Account</h2><div id="account">loading…</div></section>
<section id="admin-section" style="display:none"><h2>All accounts (admin)</h2><div id="admin" class="empty"></div></section>
<section><h2>Tracker hive</h2><div id="hive" class="empty">loading…</div></section>
<section><h2>Nodes &amp; coverage</h2><div id="nodes" class="empty">loading…</div></section>
<section><h2>Client balances</h2><div id="clients" class="empty">loading…</div></section>
@@ -193,6 +209,149 @@ function renderThroughput(stats) {
$("throughput").innerHTML = table(["node", "model", "tps (1h)", "samples"], rows);
}
// ---- account panel (registration / login / balance / usage / API keys) ----
let sessionToken = localStorage.getItem("meshnet_session") || null;
let authTab = "login";
async function apiCall(path, method, body) {
const headers = { "Content-Type": "application/json" };
if (sessionToken) headers["Authorization"] = "Bearer " + sessionToken;
try {
const r = await fetch(path, {
method: method || "GET",
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await r.json().catch(() => ({}));
return { ok: r.ok, status: r.status, data };
} catch {
return { ok: false, status: 0, data: {} };
}
}
function setSession(token) {
sessionToken = token;
if (token) localStorage.setItem("meshnet_session", token);
else localStorage.removeItem("meshnet_session");
}
function renderAuthForms(errorMsg) {
const tab = (name, label) =>
`<a class="${authTab === name ? "active" : ""}" onclick="switchAuthTab('${name}')">${label}</a>`;
const identityFields =
'<input id="auth-email" type="email" placeholder="email (or leave blank)">' +
'<input id="auth-wallet" type="text" placeholder="wallet address (or leave blank)">';
const form = authTab === "login"
? '<input id="auth-identifier" type="text" placeholder="email or wallet address">' +
'<input id="auth-password" type="password" placeholder="password">' +
'<div class="form-row"><button onclick="doLogin()">Log in</button></div>'
: identityFields +
'<input id="auth-password" type="password" placeholder="password (min 8 chars)">' +
'<div class="form-row"><button onclick="doRegister()">Create account</button></div>';
$("account").innerHTML =
`<div class="tabs">${tab("login", "Log in")}${tab("register", "Register")}</div>` +
form + `<div class="error-msg">${errorMsg ? esc(errorMsg) : ""}</div>`;
$("admin-section").style.display = "none";
}
function switchAuthTab(name) { authTab = name; renderAuthForms(); }
async function doRegister() {
const email = $("auth-email").value.trim();
const wallet = $("auth-wallet").value.trim();
const password = $("auth-password").value;
const r = await apiCall("/v1/auth/register", "POST",
{ email: email || null, wallet: wallet || null, password });
if (!r.ok) { renderAuthForms(r.data.error || "registration failed"); return; }
setSession(r.data.session_token);
await renderAccountPanel();
}
async function doLogin() {
const identifier = $("auth-identifier").value.trim();
const password = $("auth-password").value;
const r = await apiCall("/v1/auth/login", "POST", { identifier, password });
if (!r.ok) { renderAuthForms(r.data.error || "login failed"); return; }
setSession(r.data.session_token);
await renderAccountPanel();
}
async function doLogout() {
await apiCall("/v1/auth/logout", "POST", {});
setSession(null);
renderAuthForms();
}
async function createKey() {
const r = await apiCall("/v1/account/keys", "POST", {});
if (r.ok) await renderAccountPanel();
}
async function revokeKey(key) {
if (!confirm("Revoke this API key? Clients using it will stop working.")) return;
await apiCall("/v1/account/keys/revoke", "POST", { api_key: key });
await renderAccountPanel();
}
async function renderAccountPanel() {
const r = await apiCall("/v1/account");
if (r.status === 404) { // accounts disabled on this tracker
$("account-section").style.display = "none";
$("admin-section").style.display = "none";
return;
}
if (!r.ok) { setSession(null); renderAuthForms(); return; }
const { account, api_keys, balances, total_balance, usage } = r.data;
const who = account.email || account.wallet || account.account_id;
let html =
`<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` +
`<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` +
`<div style="margin:6px 0">balance: <b class="${total_balance > 0 ? "ok" : "bad"}">` +
`${usdt(total_balance)}</b> USDT · requests: <b>${usage.requests}</b> · ` +
`tokens: <b>${usage.total_tokens}</b> · spent: <b>${usdt(usage.total_cost)}</b> USDT</div>`;
html += '<div style="margin:6px 0"><b class="dim">API keys</b> ' +
'<button class="small" onclick="createKey()">+ new key</button></div>';
if (api_keys.length) {
for (const key of api_keys) {
html += `<div class="keybox">${esc(key)}` +
` <span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
}
} else {
html += '<div class="empty">no active keys</div>';
}
if (usage.recent && usage.recent.length) {
html += '<div style="margin-top:6px"><b class="dim">recent usage</b></div>' +
table(["time", "model", "tokens", "cost"], usage.recent.slice().reverse().map(u => [
new Date(u.ts * 1000).toLocaleTimeString(),
esc(short(u.model || "?", 24)),
`<span class="num">${esc(String(u.total_tokens))}</span>`,
`<span class="num">${usdt(u.cost)}</span>`,
]));
}
$("account").innerHTML = html;
if (account.role === "admin") await renderAdminPanel();
else $("admin-section").style.display = "none";
}
async function renderAdminPanel() {
const r = await apiCall("/v1/admin/accounts");
if (!r.ok) { $("admin-section").style.display = "none"; return; }
$("admin-section").style.display = "";
const rows = (r.data.accounts || []).map(a => {
const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0);
return [
esc(short(a.email || a.wallet || a.account_id, 24)),
`<span class="pill">${esc(a.role)}</span>`,
String((a.api_keys || []).length),
`<span class="num ${balance > 0 ? "ok" : "bad"}">${usdt(balance)}</span>`,
new Date(a.created_ts * 1000).toLocaleDateString(),
];
});
$("admin").innerHTML = table(["account", "role", "keys", "balance (USDT)", "created"], rows);
}
async function refresh() {
$("self-url").textContent = location.host;
const [raft, map, summary, settlements, wallets, stats] = await Promise.all([
@@ -213,7 +372,9 @@ async function refresh() {
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
}
refresh();
renderAccountPanel();
setInterval(refresh, 4000);
setInterval(() => { if (sessionToken) renderAccountPanel(); }, 8000);
</script>
</body>
</html>

View File

@@ -36,7 +36,8 @@ import uuid
from importlib.resources import files
from typing import Any
from .billing import BillingLedger
from .accounts import DEFAULT_ACCOUNTS_DB_PATH, AccountStore
from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger
from .gossip import NodeGossip
from .raft import RaftNode
@@ -1261,6 +1262,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
gossip: "NodeGossip | None" = None,
stats: "_StatsCollector | None" = None,
billing: "BillingLedger | None" = None,
accounts: "AccountStore | None" = None,
benchmark_results_path: str | None = None,
) -> None:
super().__init__(addr, handler)
@@ -1275,6 +1277,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
self.gossip = gossip
self.stats: _StatsCollector | None = stats
self.billing: BillingLedger | None = billing
self.accounts: AccountStore | None = accounts
self.benchmark_results_path = benchmark_results_path or os.path.join(
os.getcwd(), "benchmark_results.json"
)
@@ -1337,6 +1340,24 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
if self.path == "/v1/billing/forfeit":
self._handle_billing_forfeit()
return
if self.path == "/v1/auth/register":
self._handle_auth_register()
return
if self.path == "/v1/auth/login":
self._handle_auth_login()
return
if self.path == "/v1/auth/logout":
self._handle_auth_logout()
return
if self.path == "/v1/account/keys":
self._handle_account_key_create()
return
if self.path == "/v1/account/keys/revoke":
self._handle_account_key_revoke()
return
if self.path == "/v1/accounts/gossip":
self._handle_accounts_gossip()
return
if self.path == "/v1/benchmark/hop-penalty":
self._handle_benchmark_hop_penalty()
return
@@ -1382,6 +1403,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._handle_billing_summary()
elif parsed.path == "/v1/billing/settlements":
self._handle_billing_settlements()
elif parsed.path == "/v1/account":
self._handle_account_me()
elif parsed.path == "/v1/admin/accounts":
self._handle_admin_accounts()
elif parsed.path == "/v1/benchmark/results":
self._handle_benchmark_results()
elif parsed.path == "/v1/registry/wallets":
@@ -1649,6 +1674,13 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"code": "missing_api_key",
}})
return
if server.accounts is not None and server.accounts.is_key_revoked(api_key):
self._send_json(401, {"error": {
"message": "API key has been revoked",
"type": "invalid_request_error",
"code": "invalid_api_key",
}})
return
if not server.billing.has_funds(api_key):
self._send_json(402, {"error": {
"message": "insufficient balance: deposit USDT to continue",
@@ -2431,6 +2463,165 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
)
self._send_json(200, {"forfeited": event["amount"], **strike_state})
# ---- user accounts (registration, login, API keys) ----
def _session_account(self) -> dict | None:
"""Resolve the session token in the Authorization header, or None."""
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))
def _require_accounts(self) -> "AccountStore | None":
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if server.accounts is None:
self._send_json(404, {"error": "accounts are not enabled on this tracker"})
return None
return server.accounts
def _handle_auth_register(self):
accounts = self._require_accounts()
if accounts is None:
return
body = self._read_json_body()
if body is None:
return
try:
account = accounts.register(
email=body.get("email"),
wallet=body.get("wallet"),
password=str(body.get("password") or ""),
)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
token = accounts.create_session(account["account_id"])
api_key = accounts.create_api_key(account["account_id"])
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if server.billing is not None:
server.billing.ensure_client(api_key) # grant Caller Credit up front
print(
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})
def _handle_auth_login(self):
accounts = self._require_accounts()
if accounts is None:
return
body = self._read_json_body()
if body is None:
return
identifier = body.get("identifier") or body.get("email") or body.get("wallet") or ""
account = accounts.verify_login(str(identifier), str(body.get("password") or ""))
if account is None:
self._send_json(401, {"error": "invalid credentials"})
return
token = accounts.create_session(account["account_id"])
self._send_json(200, {"account": account, "session_token": 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})
def _handle_account_me(self):
"""Balance, usage, and API keys for the logged-in account."""
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if self._require_accounts() is None:
return
account = self._session_account()
if account is None:
self._send_json(401, {"error": "login required"})
return
keys = server.accounts.keys_for(account["account_id"]) # type: ignore[union-attr]
balances = {}
usage: dict = {"requests": 0, "total_tokens": 0, "total_cost": 0.0, "recent": []}
if server.billing is not None:
balances = {key: server.billing.get_client_balance(key) for key in keys}
usage = server.billing.usage_for(keys)
self._send_json(200, {
"account": account,
"api_keys": keys,
"balances": balances,
"total_balance": sum(balances.values()),
"usage": usage,
})
def _handle_account_key_create(self):
accounts = self._require_accounts()
if accounts is None:
return
account = self._session_account()
if account is None:
self._send_json(401, {"error": "login required"})
return
api_key = accounts.create_api_key(account["account_id"])
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if server.billing is not None:
server.billing.ensure_client(api_key)
self._send_json(200, {"api_key": api_key})
def _handle_account_key_revoke(self):
accounts = self._require_accounts()
if accounts is None:
return
account = self._session_account()
if account is None:
self._send_json(401, {"error": "login required"})
return
body = self._read_json_body()
if body is None:
return
api_key = body.get("api_key")
if not api_key or not isinstance(api_key, str):
self._send_json(400, {"error": "api_key is required"})
return
if not accounts.revoke_api_key(account["account_id"], api_key):
self._send_json(404, {"error": "key not found on this account"})
return
self._send_json(200, {"revoked": api_key})
def _handle_admin_accounts(self):
"""Admin-only: all accounts with their keys and balances."""
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
accounts = self._require_accounts()
if accounts is None:
return
account = self._session_account()
if account is None:
self._send_json(401, {"error": "login required"})
return
if account["role"] != "admin":
self._send_json(403, {"error": "admin role required"})
return
listing = accounts.list_accounts()
if server.billing is not None:
for entry in listing:
entry["balances"] = {
key: server.billing.get_client_balance(key)
for key in entry.get("api_keys", [])
}
self._send_json(200, {"accounts": listing})
def _handle_accounts_gossip(self):
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
body = self._read_json_body()
if body is None:
return
if server.accounts is None:
self._send_json(200, {"applied": 0})
return
events = body.get("events")
if not isinstance(events, list):
self._send_json(400, {"error": "events must be a list"})
return
applied = server.accounts.apply_events([e for e in events if isinstance(e, dict)])
self._send_json(200, {"applied": applied})
def _handle_wallet_register(self):
"""Bind the caller's client wallet pubkey to their API key (US-032).
@@ -2996,7 +3187,10 @@ class TrackerServer:
stats_db: str | None = None,
relay_url: str | None = None,
billing: BillingLedger | None = None,
enable_billing: bool = False,
billing_db: str | None = None,
accounts: AccountStore | None = None,
accounts_db: str | None = None,
benchmark_results_path: str | None = None,
treasury: Any | None = None,
deposit_poll_interval: float = 15.0,
@@ -3028,15 +3222,27 @@ class TrackerServer:
self._stats: _StatsCollector | None = _StatsCollector(db_path=stats_db) if stats_db else _StatsCollector()
self._stats_stop = threading.Event()
self._stats_thread: threading.Thread | None = None
if billing is None and billing_db:
preset_prices = {
name: float(preset["price_per_1k_tokens"])
for name, preset in self._model_presets.items()
if isinstance(preset, dict) and "price_per_1k_tokens" in preset
}
billing = BillingLedger(db_path=billing_db, prices=preset_prices)
if billing is None:
db_path = billing_db
if db_path is None and enable_billing:
db_path = DEFAULT_BILLING_DB_PATH
if db_path:
preset_prices = {
name: float(preset["price_per_1k_tokens"])
for name, preset in self._model_presets.items()
if isinstance(preset, dict) and "price_per_1k_tokens" in preset
}
billing = BillingLedger(db_path=db_path, prices=preset_prices)
self._billing: BillingLedger | None = billing
self._billing_gossip_cursor = 0
if accounts is None:
accounts_path = accounts_db
if accounts_path is None and enable_billing:
accounts_path = DEFAULT_ACCOUNTS_DB_PATH
if accounts_path:
accounts = AccountStore(db_path=accounts_path)
self._accounts: AccountStore | None = accounts
self._accounts_gossip_cursor = 0
self._benchmark_results_path = benchmark_results_path
self._treasury = treasury
self._deposit_poll_interval = deposit_poll_interval
@@ -3074,6 +3280,7 @@ class TrackerServer:
relay_url=effective_relay_url,
stats=self._stats,
billing=self._billing,
accounts=self._accounts,
benchmark_results_path=self._benchmark_results_path,
)
self.port = self._server.server_address[1]
@@ -3250,6 +3457,8 @@ class TrackerServer:
self._stats.save_to_db()
if self._billing is not None:
self._billing.save_to_db()
if self._accounts is not None:
self._accounts.save_to_db()
if self._stats is not None and self._cluster_peers:
self_url = self._cluster_self_url or f"http://{self._host}:{self.port}"
local_rpms = self._stats.get_local_rpms()
@@ -3288,6 +3497,25 @@ class TrackerServer:
# via event-id dedupe on the receiving side).
if delivered_all:
self._billing_gossip_cursor = cursor
if self._accounts is not None and self._cluster_peers:
events, cursor = self._accounts.events_since(self._accounts_gossip_cursor)
if events:
delivered_all = True
body = json.dumps({"events": events}).encode()
for peer in self._cluster_peers:
try:
req = urllib.request.Request(
f"{peer}/v1/accounts/gossip",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=2.0) as r:
r.read()
except Exception:
delivered_all = False
if delivered_all:
self._accounts_gossip_cursor = cursor
def stop(self) -> None:
if self._raft is not None:
@@ -3304,6 +3532,8 @@ class TrackerServer:
self._stats.save_to_db()
if self._billing is not None:
self._billing.save_to_db()
if self._accounts is not None:
self._accounts.save_to_db()
self._server.shutdown()
self._server.server_close()
if self._thread is not None: