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>
14 KiB
HANDOFF2 — Multi-user auth + system dashboard + public VPS hosting
Audience: the AI agent (Opus 4.8 / Claude Code) executing this build. Read
HANDOFF.md §1–3 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:
- 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.
- 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.
- Public hosting via the owner's spare VPS + Caddy reverse-proxy over Tailscale
at
modelbeast.digalot.fyi(and optionallymodelbeast.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. Cookiemb_session: HttpOnly, SameSite=Lax,Securewhen request came via https (checkx-forwarded-proto). Signing secret: settings keyauth_secret— auto-generatesecrets.token_hex(32)on first startup if unset (never expose via GET /api/settings — add it to a newHIDDEN_KEYSset). - Tokens:
Authorization: Bearer mbt_<40 hex>formbCLI / 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. |
mbCLI additions: readMB_TOKENenv → send bearer header on every request (including download/upload paths);mb token create <name>(owner, prints raw token ONCE);mb user add|list|passwdsubcommands wrapping /api/users.- Update
AGENTS.md§1/§2: agents now needMB_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 checksGET /api/meon 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_gbvia a cachedduofdata/refreshed at most every 5 min (it's slow — never on the hot path); same forhf_cache_gb. - GPU (Apple Silicon):
powermetricsneeds sudo — do NOT use it. Use macmon (brew install macmon), which reads IOReport without sudo: runmacmon 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
resourcesvia 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
assetFileURLon the job's output asset — join viaparent_jobin a smallGET /api/jobs/recentthat 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/modelbeastwould need a Vite base-path + FastAPI root-path rework across the SPA, WS, and mb CLI for zero benefit. Usemodelbeast.<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-Protofor the Secure-cookie flag (single known proxy). - Login rate-limit keyed on
X-Forwarded-Forwhen 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
- Phase 1 backend →
tests/smoke.shadditions → 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. UpdateAGENTS.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 TABLEmigrations only, via the existingMIGRATIONSlist;check_same_thread=Falseis load-bearing. - Frontend changes need
cd web && npm run build; the server servesweb/dist. - Restart via
./scripts/serve.sh; manifest changes need a restart,run.pychanges 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 jobsfor 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.