modelbeast/HANDOFF2.md
MODELBEAST 4e66cc632e HANDOFF2: execution brief — multi-user auth (guests local-only), dashboard, VPS hosting
Three-phase brief for the next build session:
1. Accounts/sessions/tokens — guests are LOCAL-ONLY by hard role rule (cloud
   operators filtered server-side; paid keys structurally unreachable), per-user
   queue caps, owner-only settings, mb CLI token auth
2. Dashboard — btop-ish snapshot-on-refresh via /api/system (psutil + macmon,
   no sudo), lanes/queue/recent-jobs view
3. Public hosting — VPS joins tailnet, Caddy reverse-proxy at
   modelbeast.digalot.fyi (subdomain not path), off-tailnet acceptance tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:10:27 +10:00

14 KiB
Raw Permalink Blame History

HANDOFF2 — Multi-user auth + system dashboard + public VPS hosting

Audience: the AI agent (Opus 4.8 / Claude Code) executing this build. Read HANDOFF.md §13 first for the architecture, operator contract, and working rules — they all still apply (one venv per tool, verify through the real browser UI, commit per milestone, push to origin, run tests/smoke.sh before each commit). This brief adds three capabilities to MODELBEAST:

  1. Accounts + login so the owner's friends can run LOCAL models on this machine over the internet — paid/cloud operators stay owner-only by hard rule, and the owner controls how much GPU anyone can hog.
  2. A dashboard page — btop-ish snapshot on page refresh (no live streaming): CPU/RAM/GPU/disk, what's running, what's queued, what ran recently.
  3. Public hosting via the owner's spare VPS + Caddy reverse-proxy over Tailscale at modelbeast.digalot.fyi (and optionally modelbeast.partyl.party).

Non-negotiable design premise — LOCAL-ONLY FOR GUESTS, HARD RULE: the owner's explicit intent is that this feature is 100% local generation on the M3 Ultra — guests cost him a tiny bit of electricity and NOTHING else. Guests must NEVER be able to (a) invoke ANY operator that uses a paid key (fal/OpenRouter/etc.) — this is role-based and NOT configurable per user, (b) read or write settings/keys, (c) delete other people's work, or (d) starve the GPU queue. Cloud operators aren't just "locked" in the UI for guests — they are filtered out server-side so the paid paths are structurally unreachable. Every task below serves one of those or the dashboard.

Current state you inherit: 22 operators, FastAPI + SQLite + lanes runner, React/Vite UI, mb CLI (stdlib, MB_HOST env), server on :8777 via scripts/serve.sh, no auth (tailnet was the boundary). Key files: server/main.py, server/db.py, server/settings.py, server/runner.py, web/src/App.jsx, mb.


Phase 1 — Accounts, sessions, tokens (backend)

1.1 Schema (additive migrations in db.py::MIGRATIONS, same pattern as asset_ids)

CREATE TABLE IF NOT EXISTS users (
    id TEXT PRIMARY KEY,            -- db.new_id()
    username TEXT UNIQUE NOT NULL,  -- lowercase, [a-z0-9_-]{2,24}
    pw_hash TEXT NOT NULL,          -- bcrypt via passlib
    role TEXT NOT NULL DEFAULT 'guest',   -- 'owner' | 'guest'; guests are LOCAL-ONLY (hard rule, no per-user cloud toggle)
    max_active_jobs INTEGER NOT NULL DEFAULT 4,
    created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS api_tokens (
    token_hash TEXT PRIMARY KEY,    -- sha256 hex of the raw token
    user_id TEXT NOT NULL,
    name TEXT NOT NULL DEFAULT '',
    created_at REAL NOT NULL
);
-- add columns: jobs.user_id TEXT, assets.user_id TEXT (nullable; legacy rows stay NULL = owner)

Deps: uv add "passlib[bcrypt]" itsdangerous psutil (server venv — all light).

1.2 Auth mechanics (server/auth.py)

  • Sessions: stateless signed cookie (itsdangerous.URLSafeTimedSerializer), payload {user_id}, max age 30 days. Cookie mb_session: HttpOnly, SameSite=Lax, Secure when request came via https (check x-forwarded-proto). Signing secret: settings key auth_secret — auto-generate secrets.token_hex(32) on first startup if unset (never expose via GET /api/settings — add it to a new HIDDEN_KEYS set).
  • Tokens: Authorization: Bearer mbt_<40 hex> for mb CLI / agents. Store only sha256. Token auth == that user, same permissions.
  • Dependency current_user(request) -> user row: cookie first, then bearer. 401 JSON on failure. Apply to EVERY /api route and the websocket (read cookie at accept; also accept ?token= query param for WS since JS can't set WS headers). Static files stay public (the SPA must load to show the login screen).
  • Login endpoints: POST /api/login {username,password} → sets cookie (rate-limit: in-memory 5 fails/minute/IP → 429); POST /api/logout; GET /api/me{username, role, allow_cloud}.
  • No self-signup. Owner manages users via CLI (1.4) and endpoints: GET/POST/PATCH/DELETE /api/users (owner-only; POST takes username+password; PATCH can reset password and set max_active_jobs).

1.3 Permission enforcement (mostly in main.py, one check in runner.py)

Action Rule
GET /api/operators for guests, filter out every operator with non-empty requires_env server-side — guests never even see the cloud operators (defense in depth, not just UI hiding).
GET/PUT /api/settings owner only — guests get 403. Never leak auth_secret or secret values to anyone (existing masking stays).
POST /api/jobs attach user_id. If operator has non-empty requires_env and role != owner → 403 "guests run local models only" (hard rule — enforced here regardless of what the UI showed). Count user's queued+running jobs; ≥ max_active_jobs → 429.
job cancel/retry/delete own jobs only, unless owner.
DELETE /api/assets/{id} own assets only, unless owner. (Viewing/downloading stays workspace-shared — it's a mates' box; note this choice in the UI copy.)
POST /api/assets (upload) any authed user; reject bodies > 1 GB early via Content-Length. Attach user_id.
/api/users* owner only.
/api/system (Phase 2) any authed user.
  • mb CLI additions: read MB_TOKEN env → send bearer header on every request (including download/upload paths); mb token create <name> (owner, prints raw token ONCE); mb user add|list|passwd subcommands wrapping /api/users.
  • Update AGENTS.md §1/§2: agents now need MB_TOKEN; show how the owner mints one.

1.4 Bootstrap

scripts/users.py (runs in server venv against the DB directly, for when you're locked out): add <username> [--owner] [--allow-cloud] (prompts for password, no echo), list, passwd <username>. On server startup, if users table is EMPTY: create user monster role=owner with a random password, print it to the server log ONCE with big banners, and require change via mb user passwd. (Owner = the human; never auto-create guests.)

1.5 Frontend (login + attribution)

  • web/src/Login.jsx: centered card, username/password, error state; App checks GET /api/me on load → render Login instead of the app when 401. Logout button + username chip in the header. On 401 from any API call, drop to login.
  • Jobs list and asset rows show the creator's username (tiny dim chip). "Mine/all" filter toggle on jobs.
  • Guests: cloud operators simply don't appear (the filtered /api/operators handles this automatically). Add a small dim footer note for guests: "local models only — everything you run is generated on this machine". Owner still sees everything.
  • WS: pass the session automatically (same-origin cookie works; add ?token= fallback).

Acceptance (Phase 1): unauthenticated API returns 401 but the login page renders; owner logs in, creates guest testmate; testmate in a private window sees ONLY local operators in the dropdown (no fal_/openrouter_ anywhere), can run flux_local, gets 403 if a fal operator id is POSTed directly to /api/jobs (curl — prove the server-side rule, not just the UI), 403 on Settings, can't delete the owner's assets, and hits 429 at 5 active jobs; MB_TOKEN makes mb ops/mb run work; smoke.sh extended for: 401-without-auth, login flow, token flow, guest-cloud-403-via-direct-POST (bootstrap a test user inside the scratch-DB server). Browser-verify with screenshots.


Phase 2 — Dashboard (snapshot on refresh)

2.1 GET /api/system (in main.py, gathering in a new server/sysinfo.py)

Snapshot, cached 5 s (in-memory timestamp guard so refresh-spam is harmless):

{
  "cpu": {"percent": 34.2, "cores": 28, "per_core": [..28 floats..], "load1": 6.1},
  "ram": {"used_gb": 181.2, "total_gb": 256.0, "percent": 70.8},
  "disk": {"free_gb": 214.0, "total_gb": 926.0, "data_dir_gb": 62.3},
  "gpu": {"util_percent": 71.0, "power_w": 89.2, "source": "macmon"} | {"source": "unavailable"},
  "uptime_s": 123456, "server_started_s": 9876,
  "lanes": {"gpu": {"limit": 1, "running": 1, "queued": 3},
            "cpu": {"limit": 3, "running": 0, "queued": 0},
            "net": {"limit": 6, "running": 2, "queued": 0}},
  "jobs_24h": {"done": 41, "error": 3, "by_operator": {"flux_local": 28, "trellis_mac": 2}},
  "hf_cache_gb": 267.0
}
  • CPU/RAM/disk/uptime: psutil (now in the server venv). data_dir_gb via a cached du of data/ refreshed at most every 5 min (it's slow — never on the hot path); same for hf_cache_gb.
  • GPU (Apple Silicon): powermetrics needs sudo — do NOT use it. Use macmon (brew install macmon), which reads IOReport without sudo: run macmon pipe -s 1 (one JSON sample), parse GPU utilization + power. Verify the exact JSON field names empirically on this machine before coding the parser — run it, look at the output, then write the parser. If macmon is missing or errors, return {"source":"unavailable"} and show "install macmon for GPU stats" in the UI — never crash the endpoint. Cache the sample with the 5 s guard (each call costs ~1 s of sampling).
  • Lanes: compute from DB (queued/running counts joined to each operator's resources via the registry) — do not reach into runner internals.

2.2 Dashboard UI (web/src/Dashboard.jsx, header tab "📊 Dashboard")

btop vibe, MODELBEAST dark theme, fetch once per mount + a ⟳ refresh button (explicitly no polling):

  • Top stat cards: CPU % (with a tiny per-core bar strip), RAM used/total with bar, GPU % + watts (or the install hint), disk free (warn tint < 100 GB).
  • Lane strip: three cards "GPU 1/1 busy · 3 waiting" style.
  • "Now running / queued" table: operator, user, elapsed (running) or position (queued), cancel button when permitted.
  • "Recent (24 h)": last ~20 finished jobs — status icon, operator, user, duration, and a thumbnail for image outputs (reuse assetFileURL on the job's output asset — join via parent_job in a small GET /api/jobs/recent that embeds output asset ids; don't N+1 from the browser).
  • A one-line machine header: "M3 Ultra · 256 GB · up 3 d 4 h · 22 operators".

Acceptance (Phase 2): dashboard renders real numbers while a flux_local job runs (GPU lane shows busy; CPU/RAM plausible); refresh button updates; endpoint answers in <1.5 s warm; works for guest accounts; screenshot-verified in the browser.


Phase 3 — VPS public hosting

The VPS is the only public surface; it reaches the M3 Ultra over the tailnet. Nothing about the M3's setup changes except that auth (Phase 1) is now mandatory before this phase ships. Do not do this phase until Phase 1 acceptance passes.

3.1 On the VPS (document as docs/VPS.md with copy-paste blocks; the owner runs these — ASK him to run them when Phase 1+2 are merged, don't assume SSH access to the VPS)

# 1) join the tailnet
curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up
# 2) Caddy (auto-HTTPS)
sudo apt install -y caddy

/etc/caddy/Caddyfile:

modelbeast.digalot.fyi {
    encode zstd gzip
    reverse_proxy 100.89.131.57:8777
}
# optional twin: modelbeast.partyl.party { reverse_proxy 100.89.131.57:8777 }

DNS: A record modelbeast → VPS public IP on each domain. sudo systemctl reload caddy.

  • Subdomain, not a path. digalot.fyi/modelbeast would need a Vite base-path + FastAPI root-path rework across the SPA, WS, and mb CLI for zero benefit. Use modelbeast.<domain>. (Say so in docs — the owner suggested the path form.)
  • Caddy proxies WebSockets out of the box; verify job-log streaming works through it.
  • Considered and rejected: Tailscale Funnel (no custom domain, bandwidth-capped, but zero-VPS — note it as the fallback if the VPS ever dies); proxy-level basic-auth (can't do per-user roles/cost-gating).

3.2 Hardening that lands in the app (do in Phase 1, verify here)

  • Trust X-Forwarded-Proto for the Secure-cookie flag (single known proxy).
  • Login rate-limit keyed on X-Forwarded-For when present.
  • Upload cap (1 GB) + request body limit in Caddy (request_body max_size 1GB).
  • The M3 keeps binding 0.0.0.0 on the tailnet — auth now protects it there too (agents/CLI use tokens; the LaunchAgent/serve.sh setup is unchanged, still 1 uvicorn worker — the in-proc queue + SQLite design requires exactly one worker; say so in a comment where serve.sh is touched, and don't "optimize" it).

Acceptance (Phase 3): from OFF the tailnet (phone on cellular): the login page loads over https at the domain, a guest can log in, run flux_local, watch the log stream, see the dashboard; mb with MB_HOST=https://modelbeast.digalot.fyi + MB_TOKEN works end-to-end; unauthenticated API probes get 401; Settings 403 for guests. Record a short before/after in BENCHMARKS.md? No — this isn't a benchmark; instead append a "Public access" section to AGENTS.md and README.


Build order & guardrails

  1. Phase 1 backend → tests/smoke.sh additions → Phase 1 frontend → browser verify → commit/push. 2. Phase 2 endpoint (verify macmon output format on-machine first) → dashboard UI → verify → commit/push. 3. docs/VPS.md + app hardening checks → hand the VPS steps to the owner → after he runs them, do the off-tailnet acceptance with him → commit/push. Update AGENTS.md (tokens), README.md (auth + dashboard + public URL), and project memory at the end.

Pitfalls from this codebase's history (respect them):

  • SQLite: additive ALTER TABLE migrations only, via the existing MIGRATIONS list; check_same_thread=False is load-bearing.
  • Frontend changes need cd web && npm run build; the server serves web/dist.
  • Restart via ./scripts/serve.sh; manifest changes need a restart, run.py changes don't.
  • Don't break the inbox watcher, the lanes, or the operator contract; don't add Celery/Redis/Postgres — the current design is deliberate.
  • Secrets never in logs (the redaction layer exists — route any new logging through it).
  • If the user's other Claude session is mid-generation (check ./mb jobs for running gpu jobs), wait for the lane to drain before restarting the server.

Scope guard: no OAuth, no email, no signup flows, no billing, no live websocket dashboards, no Grafana. The whole point is: friends type a password at a nice URL, run models within the limits the owner set, and everyone can see what the box is doing on one page.