"""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 local to each tracker and persisted so dashboard cookies survive tracker restarts. """ 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 _MAX_NICKNAME_LENGTH = 64 API_KEY_PREFIX = "sk-mesh-" _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _UNSET = object() def _normalize_nickname(value: object) -> str | None: if value is None: return None if not isinstance(value, str): raise ValueError("nickname must be a string") nickname = value.strip() if not nickname: return None if len(nickname) > _MAX_NICKNAME_LENGTH: raise ValueError(f"nickname must be at most {_MAX_NICKNAME_LENGTH} characters") return nickname 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, nickname: str | None = None, ) -> 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") nickname = _normalize_nickname(nickname) 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, "nickname": nickname, "ts": time.time(), } self._apply_locked(event) return self._public_view(self._accounts[event["account_id"]]) def update_profile(self, account_id: str, *, nickname: str | None = _UNSET) -> dict: """Update display fields for an account. Pass nickname=None to clear.""" if nickname is not _UNSET: nickname = _normalize_nickname(nickname) with self._lock: if account_id not in self._accounts: raise ValueError("unknown account") event = { "id": f"profile-{uuid.uuid4().hex}", "type": "update_profile", "account_id": account_id, "ts": time.time(), } if nickname is not _UNSET: event["nickname"] = nickname self._apply_locked(event) return self._public_view(self._accounts[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, } self._dirty = True self.save_to_db() 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: if self._sessions.pop(token, None) is not None: self._dirty = True self.save_to_db() # ---- 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 def is_active_key(self, api_key: str) -> bool: with self._lock: return api_key in self._active_keys def owner_of_key(self, api_key: str) -> str | None: with self._lock: return self._active_keys.get(api_key) # ---- views ---- def _public_view(self, record: dict) -> dict: return { "account_id": record["account_id"], "email": record.get("email"), "wallet": record.get("wallet"), "nickname": record.get("nickname"), "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"], "nickname": event.get("nickname"), "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 == "update_profile": account_id = event["account_id"] record = self._accounts.get(account_id) if record is None: return if "nickname" in event: record["nickname"] = event.get("nickname") 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.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() 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() 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: try: event = json.loads(payload) except json.JSONDecodeError: 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: if not self._db_path: return with self._lock: 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()