- demo-case-harvestlink.md + v2: fictional staged proposals for product-marketing video (no PII) - CONTRACT.md: subscription model / contact form / receipt fixes technical contract (read-only analysis 2026-08-19) - portal/index.html + ops-portal/: customer portal + ops portal single-file builds - .gitignore: demo-creds*.txt excluded
7.4 KiB
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, systemdverdicttank-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 asimport jwt as pyjwt.
auth2 (Stack Auth) constants
- Sign-in:
POST https://auth2-api.itpropartner.com/api/v1/auth/password/sign-inbody{"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_tokenis OPAQUE (not a JWT). - Current user:
GET https://auth2-api.itpropartner.com/api/latest/users/meheadersx-hexclave-access-token: <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
internalproject'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}.
- Call auth2 sign-in (above). 200 -> parse
access_token. Non-200 -> 401{"detail":"Invalid credentials"}. users/me->display_name,primary_email.- Staff check:
primary_email(oremail) in OWNER_EMAILS, OR team gate returnsitems.length > 0. - If staff: issue admin JWT (below), return
{token, user:{email, name}}. - 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_SECRETenv, else persistsecrets.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, requirerole == "staff", else 401. Reuse the existingimport 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. SendAuthorization: Bearer <token>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.origintoreport_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)
- DNS:
ops.verdicttank.comA record ->152.53.192.33. - 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 existingverdicttank.comblock addhandle /api/receipt/* { reverse_proxy 127.0.0.1:8201 }(fixes the receipt 404 for the customer portal). - auth2 team
verdicttank-it-staff+ addg@germainebrown.comas member (non-blocking: owner fallback already admits the two owners). - Write
HEXCLAVE_PUBLISHABLE_KEY=zZhcrUZXs8EqihvXzl7vqvasrRlj7kWn6+eENpX+6Eo=andHEXCLAVE_PROJECT_ID=internalto/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.