# VerdictTank Staff Dashboard — Build Contract (2026-08-18) Pinned contract for the `ops.verdicttank.com` staff dashboard. Three teams build against this. Do NOT invent fields; every field below exists in the live data or is explicitly new here. ## Goal Staff (IT Pro Partner internal) can sign in and view ALL VerdictTank submissions and their full results. Customers stay on `my.verdicttank.com` (product JWT). Staff use auth2 (Stack Auth / Hexclave). Owner-email fallback guarantees the two owners can always sign in. ## Topology (unchanged) - API: Core, `/opt/verdicttank/api.py`, `127.0.0.1:8201`, systemd `verdicttank-api.service`. - Submissions: one JSON file per review in `/opt/verdicttank/data/*.json`. - auth2: app3, `auth2-api.itpropartner.com` (API) / `auth2.itpropartner.com` (dashboard). - Python: `/root/docker/super-search/venv/bin/python3`. PyJWT imported as `import jwt as pyjwt`. ## auth2 (Stack Auth) constants - Sign-in: `POST https://auth2-api.itpropartner.com/api/v1/auth/password/sign-in` body `{"email": ..., "password": ...}`. Headers: `x-hexclave-publishable-client-key: zZhcrUZXs8EqihvXzl7vqvasrRlj7kWn6+eENpX+6Eo=`, `x-hexclave-access-type: client`, `x-hexclave-project-id: internal`, `Content-Type: application/json`. Success returns `{access_token, refresh_token, user_id}`. `access_token` is OPAQUE (not a JWT). - Current user: `GET https://auth2-api.itpropartner.com/api/latest/users/me` headers `x-hexclave-access-token: `, `x-hexclave-access-type: client`, `x-hexclave-project-id: internal`. Returns `{id, display_name, primary_email}`. - Team gate: `GET https://auth2-api.itpropartner.com/api/latest/teams?user_id=me&query=verdicttank-it-staff` (same access-token headers). Membership enforced server-side: `items.length > 0` == member. - Publishable key above is the `internal` project's key (client-visible, not a secret). - Owner emails (hardcoded fallback, always admitted): `g@germainebrown.com`, `info@itpropartner.com`, `shonuff@germainebrown.com`. All three exist as auth2 ProjectUsers (usedForAuth=TRUE): g@ = seed admin, info@ = "IT Pro Partner Info", shonuff@ = "Sho'Nuff". ## New admin endpoints (all prefixed `/api/verdicttank/admin/`) ### POST /api/verdicttank/admin/login Body `{email, password}`. 1. Call auth2 sign-in (above). 200 -> parse `access_token`. Non-200 -> 401 `{"detail":"Invalid credentials"}`. 2. `users/me` -> `display_name`, `primary_email`. 3. Staff check: `primary_email` (or `email`) in OWNER_EMAILS, OR team gate returns `items.length > 0`. 4. If staff: issue admin JWT (below), return `{token, user:{email, name}}`. 5. If not staff: 403 `{"detail":"Not authorized"}`. ### GET /api/verdicttank/admin/submissions (admin JWT) Query: `status` (optional), `q` (optional: case-insensitive match on name/email/proposal_name), `limit` (default 100, max 500), `offset` (default 0). Returns `{submissions:[...], total:N}`. Each item: `review_id, proposal_name, name, email, tier, tier_label, status, verdict, proposal_strength, investor_readiness, composite, submitted_at, completed_at`. Use the same pattern as the existing `/reviews` handler: `for path in sorted(DATA_DIR.glob("*.json"), reverse=True)`. ### GET /api/verdicttank/admin/submissions/{review_id} (admin JWT) Full detail — every field in `/results/{id}` PLUS the staff-only fields: `review_id, status, proposal_name, name, email, user_id, tier, tier_label, submitted_at, completed_at, verdict, verdict_rationale, executive_summary, proposal_strength, investor_readiness, composite, divergence, dimensions, fatal_flaws, action_plan, kill_criteria, consensus, panel_audit, report_url, receipt_url, error`. `report_url` = `/api/verdicttank/report/{id}` (only when status == complete, else null). `receipt_url` = `/api/receipt/{id}` (only when receipt_path exists). ### GET /api/verdicttank/admin/stats (admin JWT) Returns `{total, completed, failed, queued, processing, by_tier:{tier:count,...}, by_status:{...}}`. Compute from all `*.json` in DATA_DIR. ## Admin JWT (separate from customer JWT) - Secret: `VERDICTTANK_ADMIN_JWT_SECRET` env, else persist `secrets.token_urlsafe(48)` to `/opt/verdicttank/.admin_jwt_secret` (mode 0600). DO NOT reuse the customer `.jwt_secret`. - Algo HS256, TTL 12 hours. - Claims: `{sub: email, role: "staff", name: name, exp, iat}`. - Dependency `require_staff(authorization: str = Header(None))`: decode with the admin secret, require `role == "staff"`, else 401. Reuse the existing `import jwt as pyjwt`. ## Submission JSON fields (already in data files) `review_id, status(queued|processing|complete|failed), proposal_name, name, email, tier, submitted_at, completed_at, verdict, verdict_rationale, executive_summary, proposal_strength, investor_readiness, composite, divergence, dimensions(list of {key,label,score}), fatal_flaws, action_plan, kill_criteria, consensus, panel_audit(list), report_path, receipt_path, progress, error, user_id(optional)`. Existing helpers in api.py: `load_submission(review_id)`, `save_submission(review_id, sub)`, `DATA_DIR`, `TIERS`, `tier_label(tier)`, `current_user(authorization)`. ## Staff frontend (ops.verdicttank.com) — single-file SPA Served from Core Caddy (see infra). The SPA calls the API SAME-ORIGIN via the `/api/*` reverse proxy, so base = `""` (relative), e.g. `fetch('/api/verdicttank/admin/login', ...)`. No CORS, no cross-origin auth2 calls from the browser — all auth2 interaction is server-side in the API. - Login screen: email + password -> `POST /api/verdicttank/admin/login` -> store `{token, user}` in localStorage. Send `Authorization: Bearer ` on all admin calls. - 401/403 -> show access-denied / re-show login. - Dashboard: stats bar from `/api/verdicttank/admin/stats`; submissions table from `/api/verdicttank/admin/submissions`; search box (`q`), status filter (`status`), pagination. - Row click -> `/api/verdicttank/admin/submissions/{id}` -> detail view: two 0-100 scores, 10 dimensions (0-10 bars), verdict + rationale, executive summary, fatal flaws, action plan, kill criteria, consensus, panel audit (collapsible), links to report_url and receipt_url. - Download links: prepend `window.location.origin` to `report_url` / `receipt_url`. Brand rules (non-negotiable): crimson `#dc2626`, Inter font (Google Fonts ok), dark/light toggle, NO vendor/model names anywhere, NO em/en dashes (use hyphens), no fabricated numbers (render real values only), footer "VerdictTank, a product of IT Pro Partner". Single-file HTML/CSS/JS, vanilla, no frameworks, no CDNs beyond Google Fonts. ## Infra (Core + DNS + auth2) 1. DNS: `ops.verdicttank.com` A record -> `152.53.192.33`. 2. Caddy (Core `/etc/caddy/Caddyfile`) — add block: `ops.verdicttank.com { root * /var/www/verdicttank-ops; file_server; handle /api/* { reverse_proxy 127.0.0.1:8201 } header Cache-Control "no-cache" }` and in the existing `verdicttank.com` block add `handle /api/receipt/* { reverse_proxy 127.0.0.1:8201 }` (fixes the receipt 404 for the customer portal). 3. auth2 team `verdicttank-it-staff` + add `g@germainebrown.com` as member (non-blocking: owner fallback already admits the two owners). 4. Write `HEXCLAVE_PUBLISHABLE_KEY=zZhcrUZXs8EqihvXzl7vqvasrRlj7kWn6+eENpX+6Eo=` and `HEXCLAVE_PROJECT_ID=internal` to `/etc/verdicttank.env` (API reads via os.getenv). ## Deploy order (conductor enforces) Backend module -> `py_compile` -> `systemctl restart verdicttank-api` -> Caddy reload -> DNS propagate -> frontend deployed to `/var/www/verdicttank-ops/index.html` -> e2e test.