diff --git a/pyproject.toml b/pyproject.toml
index 0c44a80..74b3cd4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,9 @@ dependencies = [
"structlog>=24.1",
"sqlalchemy>=2.0",
"python-dotenv>=1.0",
+ "pyotp>=2.9",
+ "qrcode[pil]>=7.4",
+ "itsdangerous>=2.1",
]
[project.optional-dependencies]
diff --git a/src/psyc/cockpit/adminauth.py b/src/psyc/cockpit/adminauth.py
new file mode 100644
index 0000000..4cb6feb
--- /dev/null
+++ b/src/psyc/cockpit/adminauth.py
@@ -0,0 +1,88 @@
+"""Admin gate — TOTP (authenticator-app) auth for the hidden /admin zone.
+
+A single TOTP secret is provisioned by scanning a QR code into an authenticator
+app (Google Authenticator, Authy, …). After that, entry to /admin requires the
+current rotating 6-digit code. Secret + session key persist under DATA_DIR so
+they survive restarts; the file is gitignored with the rest of data/.
+"""
+
+from __future__ import annotations
+
+import base64
+import io
+import json
+import secrets
+from pathlib import Path
+from typing import Tuple
+
+import pyotp
+import qrcode
+
+from psyc import DATA_DIR, log
+
+
+_log = log.get(__name__)
+
+_STATE_PATH = DATA_DIR / "admin_auth.json"
+_ISSUER = "psyc"
+_ACCOUNT = "admin"
+
+
+def _load() -> dict:
+ if _STATE_PATH.exists():
+ return json.loads(_STATE_PATH.read_text())
+ state = {
+ "totp_secret": pyotp.random_base32(),
+ "session_secret": secrets.token_urlsafe(32),
+ "provisioned": False,
+ }
+ _save(state)
+ _log.info("adminauth.initialized", path=str(_STATE_PATH))
+ return state
+
+
+def _save(state: dict) -> None:
+ _STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
+ _STATE_PATH.write_text(json.dumps(state, indent=2))
+
+
+def session_secret() -> str:
+ return _load()["session_secret"]
+
+
+def is_provisioned() -> bool:
+ return bool(_load().get("provisioned"))
+
+
+def mark_provisioned() -> None:
+ state = _load()
+ if not state.get("provisioned"):
+ state["provisioned"] = True
+ _save(state)
+ _log.info("adminauth.provisioned")
+
+
+def verify(code: str) -> bool:
+ """Check a 6-digit TOTP code (±1 window for clock skew)."""
+ secret = _load()["totp_secret"]
+ ok = pyotp.TOTP(secret).verify(code.strip(), valid_window=1)
+ if ok:
+ mark_provisioned()
+ _log.info("adminauth.verify", ok=ok)
+ return ok
+
+
+def provisioning_qr_data_uri() -> Tuple[str, str]:
+ """Return (otpauth_uri, data:image/png;base64 QR) for authenticator setup."""
+ secret = _load()["totp_secret"]
+ uri = pyotp.TOTP(secret).provisioning_uri(name=_ACCOUNT, issuer_name=_ISSUER)
+ img = qrcode.make(uri)
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ data_uri = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
+ return uri, data_uri
+
+
+def current_code() -> str:
+ """The code right now — used only by tests / local verification, never shown."""
+ return pyotp.TOTP(_load()["totp_secret"]).now()
diff --git a/src/psyc/cockpit/app.py b/src/psyc/cockpit/app.py
index 292a6b6..c42d68b 100644
--- a/src/psyc/cockpit/app.py
+++ b/src/psyc/cockpit/app.py
@@ -9,9 +9,10 @@ from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
+from starlette.middleware.sessions import SessionMiddleware
from psyc import db, log
-from psyc.cockpit import inference, journey as journey_view
+from psyc.cockpit import adminauth, inference, journey as journey_view
from psyc.lines import courier as courier_line
from psyc.lines import ledger as ledger_line
from psyc.lines import lookup as lookup_line
@@ -30,9 +31,14 @@ log.configure()
_log = log.get(__name__)
app = FastAPI(title="psyc Operations Cockpit", version="0.1.0")
+app.add_middleware(SessionMiddleware, secret_key=adminauth.session_secret(), max_age=3600)
app.mount("/static", StaticFiles(directory=str(HERE / "static")), name="static")
+def _admin_ok(request: Request) -> bool:
+ return bool(request.session.get("admin_ok"))
+
+
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
cases = db.list_cases(limit=200)
@@ -174,6 +180,33 @@ def response_reject(action_id: int, approver: str = Form("operator"), reason: st
return RedirectResponse("/response", status_code=303)
+# ---------- hidden admin zone (TOTP-gated) -------------------------------
+
+@app.get("/admin", response_class=HTMLResponse)
+def admin_home(request: Request) -> HTMLResponse:
+ if _admin_ok(request):
+ return TEMPLATES.TemplateResponse(request, "admin.html", {})
+ # Not authenticated — show the gate. QR only until first provisioned.
+ ctx = {"provisioned": adminauth.is_provisioned(), "error": request.query_params.get("error", "")}
+ if not ctx["provisioned"]:
+ _, ctx["qr"] = adminauth.provisioning_qr_data_uri()
+ return TEMPLATES.TemplateResponse(request, "admin_gate.html", ctx)
+
+
+@app.post("/admin/verify")
+def admin_verify(request: Request, code: str = Form(...)) -> RedirectResponse:
+ if adminauth.verify(code):
+ request.session["admin_ok"] = True
+ return RedirectResponse("/admin", status_code=303)
+ return RedirectResponse("/admin?error=1", status_code=303)
+
+
+@app.get("/admin/logout")
+def admin_logout(request: Request) -> RedirectResponse:
+ request.session.pop("admin_ok", None)
+ return RedirectResponse("/admin", status_code=303)
+
+
@app.get("/queue", response_class=HTMLResponse)
def queue_view(request: Request, status: str = "pending") -> HTMLResponse:
from psyc.models import ApprovalStatus
diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css
index d55779f..bf3c271 100644
--- a/src/psyc/cockpit/static/cockpit.css
+++ b/src/psyc/cockpit/static/cockpit.css
@@ -375,3 +375,33 @@ tr.sev-low .sev-badge { color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
.disco-strobe, .disco-bolt, .ioc-fly { animation: none; }
}
+
+/* ── admin gate (TOTP) ──────────────────────────────────────── */
+.gate-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
+.gate-card {
+ width: 360px; max-width: 100%; text-align: center; padding: 32px 28px;
+ background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
+ box-shadow: 0 0 40px rgba(0,0,0,0.5); position: relative;
+}
+.gate-card::before, .gate-card::after { content: ""; position: absolute; width: 16px; height: 16px; border: 2px solid var(--accent); opacity: 0.6; }
+.gate-card::before { top: 8px; left: 8px; border-right: none; border-bottom: none; }
+.gate-card::after { bottom: 8px; right: 8px; border-left: none; border-top: none; }
+.gate-logo { width: 56px; height: 56px; filter: drop-shadow(0 0 10px var(--accent-glow)); }
+.gate-title { font-family: var(--font-display); font-size: 22px; margin: 12px 0 4px; letter-spacing: 0.08em; text-shadow: 0 0 16px var(--accent-glow); }
+.gate-sub { color: var(--muted); font-size: 13px; margin: 0 0 18px; }
+.gate-setup { margin: 16px 0; padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; }
+.gate-step { font-size: 12px; color: var(--text); margin: 0 0 12px; }
+.gate-qr { width: 168px; height: 168px; border-radius: 6px; background: #fff; padding: 6px; }
+.gate-error { color: var(--red); font-size: 13px; margin: 12px 0; }
+.gate-form { display: flex; gap: 8px; margin-top: 14px; }
+.gate-input {
+ flex: 1; background: var(--bg); color: var(--text); border: 1px solid var(--border);
+ border-radius: 6px; padding: 10px; font: inherit; font-size: 22px; letter-spacing: 0.4em; text-align: center;
+}
+.gate-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
+.gate-btn { padding: 10px 16px; }
+.gate-hint { color: var(--muted); font-size: 11px; margin-top: 12px; }
+.admin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; margin-top: 18px; }
+.admin-tile { padding: 16px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; }
+.admin-tile h2 { font-size: 15px; margin: 0 0 6px; }
+.admin-tile p { font-size: 13px; color: var(--muted); margin: 0; }
diff --git a/src/psyc/cockpit/templates/admin.html b/src/psyc/cockpit/templates/admin.html
new file mode 100644
index 0000000..51ee68e
--- /dev/null
+++ b/src/psyc/cockpit/templates/admin.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% block title %}Admin — psyc{% endblock %}
+{% block content %}
+ Authenticated. This is the secured zone — TOTP-gated, hidden from the nav. Infrastructure visibility and the rest of the control system get built in here. Live container + network map. Wiring next (stage-26b) via a read-only socket-proxy.Admin Control Center
+ lock ⏻
+ Docker topology
+
+ Admin control center — authenticator required.
+ + {% if not provisioned %} +First time? Scan this with Google Authenticator / Authy, then enter the code below.
+6-digit code, rotates every 30s.
+