skills update; USER ACCOUNT system! Alpha!
This commit is contained in:
299
packages/tracker/meshnet_tracker/accounts.py
Normal file
299
packages/tracker/meshnet_tracker/accounts.py
Normal 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()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user