From 6ac8a185efcfa5b5acd3e701a6dde48663ad4510 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 26 Aug 2026 02:26:50 -0400 Subject: [PATCH] Add demo cases (HarvestLink v1/v2), subscription technical contract, portal + ops-portal HTML - 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 --- .gitignore | 4 +- CONTRACT.md | 537 ++++++++++++++++++ demo-case-harvestlink-v2.md | 69 +++ demo-case-harvestlink.md | 42 ++ ops-portal/CONTRACT.md | 125 +++++ ops-portal/index.html | 1060 +++++++++++++++++++++++++++++++++++ portal/index.html | 889 +++++++++++++++++++++++++++++ 7 files changed, 2725 insertions(+), 1 deletion(-) create mode 100644 CONTRACT.md create mode 100644 demo-case-harvestlink-v2.md create mode 100644 demo-case-harvestlink.md create mode 100644 ops-portal/CONTRACT.md create mode 100644 ops-portal/index.html create mode 100644 portal/index.html diff --git a/.gitignore b/.gitignore index 088084a..3fd7cae 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,6 @@ credentials.json # Project-specific *.db-journal -*.db-wal \ No newline at end of file +*.db-wal +# Demo credentials (never commit) +demo-creds*.txt diff --git a/CONTRACT.md b/CONTRACT.md new file mode 100644 index 0000000..5f57308 --- /dev/null +++ b/CONTRACT.md @@ -0,0 +1,537 @@ +# VerdictTank — Subscription Model, Contact Form, Receipt Fixes: Technical Contract + +Status: DRAFT for review. Read-only analysis of the live codebase on Core (152.53.192.33) as of +2026-08-19. No code was modified, no service restarted, no schema migration applied. + +Surfaces inspected: +- Backend API: `/opt/verdicttank/api.py` (989 lines, FastAPI, systemd `verdicttank-api.service`, port 8201 behind Caddy) +- Database: `/opt/verdicttank/users.db` (SQLite) +- Ops admin portal: `/var/www/verdicttank-ops/index.html` (1060 lines, static SPA, served at ops.verdicttank.com, calls `/api/verdicttank/admin/*`) +- Marketing site: `/var/www/verdicttank/index.html` (1602 lines, served at verdicttank.com) +- Client portal source: `/root/projects/verdicttank-mockups/portal-index.html` (1068 lines) — source of the LIVE file on app3 at `/home/myverdicttank/htdocs/my.verdicttank.com/index.html`, served at my.verdicttank.com + +Canonical tier quotas used throughout this contract (authoritative, not invented): + +| Tier | Included / term | Term | Overage price/each | +|---|---|---|---| +| Free | 1 | lifetime | none (hard stop) | +| One-Shot | 1 | one-time | none (hard stop) | +| Pro | 8 | monthly | $18 | +| Enterprise | 50 | monthly | $16 | +| White-Label | 100 | monthly | $28 | + +--- + +## 1. CURRENT API INVENTORY + +All routes are under `PREFIX = "/api/verdicttank"` unless noted. FastAPI app `verdicttank-api`, +`version="0.2.0"`, CORS allows `https://verdicttank.com`, `https://proposals.iamgmb.com`, +`https://core.itpropartner.com`, `https://my.verdicttank.com`; methods GET/POST/OPTIONS only. + +### Public / health +- `GET /api/verdicttank/health` → `{status:"ok", service:"verdicttank-api", version:"0.2.0"}` + +### Customer auth (embedded JWT, no social, no Stack Auth) +- `POST /api/verdicttank/auth/register` — body `{email, password, name?}` → `{token, user:{id,email,name}}`. 400 invalid email, 400 password<8 chars, 409 duplicate email. +- `POST /api/verdicttank/auth/login` — body `{email, password}` → `{token, user:{id,email,name}}`. 401 bad creds, 429 after 5 failures/15min (in-memory, resets on restart). +- `GET /api/verdicttank/auth/me` — Bearer → `{user:{id,email,name,created_at}}`. 401 if none. +- `POST /api/verdicttank/auth/forgot-password` — body `{email}` → `{message}` (always 200, no account enumeration). Sends reset email via `smtplib` if account exists. +- `POST /api/verdicttank/auth/reset-password` — body `{token, new_password}` → `{message}`. 400 invalid/expired token, 400 password<8. +- `POST /api/verdicttank/auth/change-password` — Bearer, body `{current_password, new_password}` → `{message}`. 401 not authed, 400 wrong current password / short new password. + +### Submission / results +- `POST /api/verdicttank/submit` — multipart form: `name`(str,""), `email`(str,""), `proposal_name`(required), `proposal_text`(str,""), `proposal_file`(UploadFile, optional), `tier`(str,"free"), optional Bearer. Invalid tier silently coerces to `"free"` — **no rejection, no quota check today**. 422 if name/email missing or neither text nor file. Response (`SubmissionResponse`): `{review_id, status:"queued", message, submitted_at}`. +- `GET /api/verdicttank/status/{review_id}` → `{review_id, status, verdict, proposal_strength, investor_readiness, composite, progress, report_url, submitted_at, completed_at}`. 404 unknown id. +- `GET /api/verdicttank/results/{review_id}` → full structured result: `review_id, status, proposal_name, name, 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[], report_url, error`. +- `GET /api/verdicttank/reviews` — Bearer → `{reviews:[{review_id, proposal_name, tier, tier_label, status, verdict, proposal_strength, investor_readiness, composite, submitted_at, completed_at}]}` (only rows where `user_id` matches). 401 if no Bearer. +- `GET /api/verdicttank/report/{review_id}` → PDF file or 404 until `status=="complete"`. +- `GET /api/receipt/{review_id}` (note: **no `/verdicttank` prefix**, pre-existing quirk) → receipt PDF, no auth required, 404 if not generated. + +### Staff/admin (separate auth track, see section 3) +- `POST /api/verdicttank/admin/login` — body `{email, password}` → `{token, user:{email,name}}`. Delegates to auth2/Hexclave password sign-in, then a staff-team or owner-email check; 401 bad creds, 403 not staff. +- `GET /api/verdicttank/admin/submissions?status=&q=&limit=&offset=` — Bearer staff → `{submissions:[...], total}` (all users, filterable, `limit` clamped 1-500). +- `GET /api/verdicttank/admin/submissions/{review_id}` — Bearer staff → full detail incl. `user_id`, `panel_audit`, `receipt_url`. +- `GET /api/verdicttank/admin/stats` — Bearer staff → `{total, completed, failed, queued, processing, by_status:{}, by_tier:{}}`. + +### Misc +- `POST /api/verdicttank/assist` — body `{message, section_id, section_title, current_content, full_draft}` → `{reply}`. Calls internal LLM (`admin-ai.itpropartner.com`, DeepSeek Flash) for proposal-builder brainstorming. No auth. Unrelated to subscription work but shares the router. + +**No Stripe or any payment-provider code exists anywhere in api.py.** `tier` on `/submit` is a +free-text form field with zero enforcement — a client can submit `enterprise` with no account and +no charge. There is no per-user submission counter, no quota table, no renewal/expiration logic. + +--- + +## 2. CURRENT DATA MODEL + +`/opt/verdicttank/users.db`, SQLite, confirmed via `sqlite3 ... ".schema"`: + +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE TABLE sqlite_sequence(name, seq); -- auto-managed by SQLite for AUTOINCREMENT +``` + +That is the **entire** schema. No `tier`, `plan_status`, `term`, `quota`, `renewal`, `expiration`, +`cancelled_at`, `stripe_customer_id`, or any subscription-related column exists on `users`, and +there is no second table for subscriptions, invites, quota grants, or notifications. + +Submissions themselves are **not** in SQLite — each submission is a flat JSON file at +`/opt/verdicttank/data/{review_id}.json` (fields: review_id, name, email, user_id, proposal_name, +proposal_text, proposal_file, tier, status, submitted_at, completed_at, verdict, report_path, +receipt_path, plus fields the worker fills in later: proposal_strength, investor_readiness, +composite, divergence, dimensions, fatal_flaws, action_plan, kill_criteria, consensus, +panel_audit, executive_summary, verdict_rationale, error, progress). There is no SQL table +indexing submissions by user_id/tier/date — the API scans `DATA_DIR.glob("*.json")` for +`/reviews`, `/admin/submissions`, and `/admin/stats`. Any subscription/quota logic that needs to +count "submissions this term" must either scan this JSON directory (works today, gets slower as +volume grows) or maintain a running counter in SQLite (recommended — see Section 6). + +--- + +## 3. AUTH MODEL (as actually implemented — confirmed, not assumed) + +There is **no Stack Auth / Hexclave auth2 for customers**. Two entirely separate, non-interoperable +auth tracks exist in the same file: + +### 3a. Customer auth — self-contained embedded JWT +- Identity source: local SQLite `users` table only. +- Password hashing: stdlib `hashlib.pbkdf2_hmac("sha256", ..., 200_000 iterations)`, stored as + `pbkdf2_sha256$$$`. bcrypt is NOT installed/used. +- Token: PyJWT (`import jwt as pyjwt`), algorithm HS256, 30-day TTL (`TOKEN_TTL_SECONDS = 60*60*24*30`). + Payload: `{sub: str(user_id), email, exp}`. +- Transport: `Authorization: Bearer ` header only. No cookies, no session store. +- Secret: `_load_jwt_secret()` reads env `VERDICTTANK_JWT_SECRET` first; else reads/creates + `/opt/verdicttank/.jwt_secret` (0600, `secrets.token_urlsafe(48)`). **Confirmed on disk**: + `.jwt_secret` EXISTS (64 bytes, 0600, root-owned, last modified 2026-08-18 21:38), alongside + `.admin_jwt_secret` (64 bytes, 2026-08-18 23:15). `/etc/verdicttank.env` does NOT define + `VERDICTTANK_JWT_SECRET`, so the on-disk file is the live secret source and persists across + restarts. No secret-persistence concern. +- Password reset: separate short-lived JWT (`purpose:"password_reset"`, 1h TTL), same secret, + emailed via direct `smtplib` to `mail.itpropartner.com:2525` (creds in `/etc/verdicttank.env`). + +### 3b. Staff/admin auth — auth2 (Hexclave) delegated, JWT re-mint +- `POST admin/login` receives `{email,password}`, calls auth2 password sign-in + (`https://auth2-api.itpropartner.com/api/v1/auth/password/sign-in`) server-side using + `HEXCLAVE_PUBLISHABLE_KEY`/`HEXCLAVE_PROJECT_ID` from env, then `GET users/me`. +- Staff gate: hardcoded `OWNER_EMAILS = {g@germainebrown.com, info@itpropartner.com, + shonuff@germainebrown.com}` bypasses the team check; otherwise queries auth2 team + `verdicttank-it-staff` via `GET /api/latest/teams?user_id=me&query=verdicttank-it-staff`. +- On success, api.py mints ITS OWN JWT (not an auth2 token) with `{sub: email, role:"staff", name, + iat, exp}`, HS256, 12h TTL, signed with a **separate** secret: env `VERDICTTANK_ADMIN_JWT_SECRET` + else `/opt/verdicttank/.admin_jwt_secret` (0600). **Confirmed on disk**: this file exists, + 64 bytes, root-owned, `-rw-------`, last modified 2026-08-18. +- `require_staff()` dependency decodes with the admin secret and requires `role=="staff"`. Because + the two JWT secrets are different, a customer token can never be replayed against an admin route. +- No customer identity is used anywhere in the admin path — admin/staff users are NOT rows in + `users.db`. This means admin actions in a1/a2 below (invite, cancel) must look up the *target + customer* by email in `users.db`, not by staff identity. + +### Implication for the subscription build +Any new admin endpoint (invite, cancel-subscription, quota grant) is protected by `require_staff` +exactly like the existing four admin endpoints — no new auth mechanism is needed. Any new +client-facing endpoint (subscription-state, contact, quota display) is protected by the existing +`current_user(authorization)` helper exactly like `/reviews`. No Stack Auth exists for customers +and none should be introduced — do not add Hexclave dependencies to the customer path. + +--- + +## 4. PROPOSED SCHEMA MIGRATION + +Two changes: (1) extend `users` with subscription/quota columns, (2) add three new tables for +invites, extra-submission grants, and ops notifications. All statements are additive +(`ALTER TABLE ... ADD COLUMN` / `CREATE TABLE IF NOT EXISTS`) so they are safe to run against the +live `users.db` without data loss; SQLite ALTER TABLE ADD COLUMN never rewrites existing rows and +existing users get the column default. + +```sql +-- 4.1: extend users with tier/subscription state +ALTER TABLE users ADD COLUMN tier TEXT NOT NULL DEFAULT 'free'; + -- one of: free | oneshot | pro | enterprise | whitelabel + +ALTER TABLE users ADD COLUMN subscription_status TEXT NOT NULL DEFAULT 'active'; + -- one of: active | pending_cancel | expired | demo + +ALTER TABLE users ADD COLUMN term_start TEXT; + -- ISO8601 UTC; start of current billing/quota term. NULL for free/oneshot (no recurring term). + +ALTER TABLE users ADD COLUMN term_end TEXT; + -- ISO8601 UTC; renewal date for monthly tiers (pro/enterprise/whitelabel). + -- For demo accounts, this doubles as the auto-expiration timestamp (see a1). + +ALTER TABLE users ADD COLUMN cancel_at_term_end INTEGER NOT NULL DEFAULT 0; + -- 0/1 boolean. Set to 1 by a2 (cancel). Subscription stays 'active' with full quota until + -- term_end, then a scheduled job (see 6.4) flips tier to 'free' and status to 'expired'. + +ALTER TABLE users ADD COLUMN submissions_used_this_term INTEGER NOT NULL DEFAULT 0; + -- Running counter of submissions consumed against the included quota in the CURRENT term. + -- Reset to 0 whenever term_start advances (monthly rollover) — see 6.3. + +ALTER TABLE users ADD COLUMN extra_submissions_granted INTEGER NOT NULL DEFAULT 0; + -- Cumulative count of manually-admin-granted overage submissions available beyond the + -- included quota for the CURRENT term. Since there is no payment provider, this is set only + -- by an admin action (b3 "paid additional submissions" are recorded manually). + +ALTER TABLE users ADD COLUMN extra_submissions_used INTEGER NOT NULL DEFAULT 0; + -- Of extra_submissions_granted, how many have been consumed this term. + +ALTER TABLE users ADD COLUMN is_demo INTEGER NOT NULL DEFAULT 0; + -- 1 if this account was created via the ops "create demo account" flow (a1). + +ALTER TABLE users ADD COLUMN demo_expires_at TEXT; + -- ISO8601 UTC; set only for demo accounts. Same auto-expire mechanism as term_end but kept + -- as a distinct column so a real subscriber's term_end is never confused with a demo cutoff. + +-- 4.2: invite-to-register tokens (a1) +CREATE TABLE IF NOT EXISTS invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + email TEXT NOT NULL, + tier TEXT NOT NULL DEFAULT 'free', + is_demo INTEGER NOT NULL DEFAULT 0, + expires_in_days INTEGER NOT NULL DEFAULT 14, -- the X in "X-day expiration" + created_by TEXT NOT NULL, -- staff email from admin JWT + created_at TEXT NOT NULL, + redeemed_at TEXT, -- NULL until the invite is used + redeemed_user_id INTEGER, + invite_expires_at TEXT NOT NULL -- the invite LINK's own expiry (separate from + -- the resulting demo account's expiry) +); + +-- 4.3: extra-submission grant ledger (b3 — audit trail for manually-added overage) +CREATE TABLE IF NOT EXISTS submission_grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + quantity INTEGER NOT NULL, -- positive = granted, negative = consumed (optional; or + -- keep consumption as a simple counter on users and use + -- this table purely as a grant-only audit log) + reason TEXT, -- free text, e.g. "manual overage payment received 8/19" + granted_by TEXT NOT NULL, -- staff email + granted_at TEXT NOT NULL +); + +-- 4.4: ops notifications (b1 contact form) +CREATE TABLE IF NOT EXISTS ops_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL DEFAULT 'contact', -- extensible: contact | cancellation | demo_expiring + payload TEXT NOT NULL, -- JSON blob: {name, email, subject, message, user_id} + created_at TEXT NOT NULL, + read_at TEXT -- NULL until a staff member marks it read in ops portal +); +``` + +Notes: +- `submissions_used_this_term` + `extra_submissions_used` together define "remaining" (Section 6). +- Free and One-Shot are lifetime/one-time, so `term_start`/`term_end` stay NULL for them; quota + enforcement instead checks `submissions_used_this_term >= 1` for the life of the account + (Free) or the single submission ever made (One-Shot) — see 6.1/6.2. +- No `stripe_customer_id` or any payment column is added, matching the "no Stripe exists" + constraint. `submission_grants` is the entire "billing" record for overage — a manual ledger. + +--- + +## 5. PROPOSED NEW ENDPOINTS + +All new endpoints live under the existing `PREFIX = "/api/verdicttank"` router in api.py. Admin +endpoints reuse `require_staff()`; client endpoints reuse `current_user()`. + +### a1. Ops — invite-to-register + create demo account + +**`POST /api/verdicttank/admin/invite`** (staff Bearer) +```json +// Request +{ + "email": "prospect@example.com", + "tier": "pro", + "is_demo": true, + "expires_in_days": 14 +} +// Response 200 +{ + "invite_token": "3f9c1a...e2", + "invite_url": "https://my.verdicttank.com/?invite=3f9c1a...e2", + "email": "prospect@example.com", + "tier": "pro", + "is_demo": true, + "expires_in_days": 14, + "invite_expires_at": "2026-09-02T00:00:00+00:00" +} +``` +Behavior: inserts a row into `invites` (`invite_expires_at = now + expires_in_days` for the LINK +itself, default 7 days for the link unless staff overrides — the "X-day expiration" in the task +refers to the resulting **demo account's** lifetime, not necessarily the invite link's; if the +same X should govern both, set link expiry = `expires_in_days` too). Does NOT create the user yet. +409 if an unredeemed invite already exists for that email+tier combo (optional; else always allow +multiple). Sends an email to the invitee with `invite_url` (reuses the existing `send_email()` / +SMTP helper). + +**`GET /api/verdicttank/invite/{token}`** (public, no auth) +```json +// Response 200 +{"email": "prospect@example.com", "tier": "pro", "is_demo": true, "valid": true} +// Response 410 (expired or already redeemed) +{"detail": "This invite link is no longer valid."} +``` +Used by the client portal to pre-fill/lock the registration form when `?invite=` is present. + +**`POST /api/verdicttank/auth/register`** (existing endpoint, extended) +```json +// Request adds optional field +{"email": "...", "password": "...", "name": "...", "invite_token": "3f9c1a...e2"} +``` +If `invite_token` is present and valid: on success, set the new user's `tier`, `is_demo=1`, +`demo_expires_at = now + expires_in_days`, `term_start/term_end` per tier rules, mark the invite +row `redeemed_at`/`redeemed_user_id`. If invalid/expired, 410 (do not silently fall back to free — +surface the error so the client can prompt for a fresh invite). + +**Auto-expiration mechanism**: no code path runs on a timer inside api.py today (no scheduler). +Two implementation options, pick one explicitly before building: +1. **Lazy expiry** (recommended, zero new infra): every place that reads `tier`/quota + (`current_user`, `/submit`, `/subscription-state`) checks `is_demo=1 AND demo_expires_at < now` + and, if true, downgrades the row to `tier='free', subscription_status='expired', is_demo=0` in + that same request before proceeding. Self-healing, no new process, but a demo that no one + touches after expiry stays "expired-but-not-yet-flagged" in the DB until next access — cosmetic + only, does not affect enforcement since /submit checks it too. +2. **Cron sweep**: a new script (`/opt/verdicttank/expire_demos.py`) run via systemd timer/cron + that scans `WHERE is_demo=1 AND demo_expires_at < now` and downgrades in bulk. More visible in + `admin/stats`/ops list at all times, but is new infrastructure this contract does not currently + assume exists — would need its own service unit, outside api.py's process model. + +### a2. Ops — cancel subscription, effective at end of term + +**`POST /api/verdicttank/admin/cancel-subscription`** (staff Bearer) +```json +// Request +{"user_id": 42} +// Response 200 +{ + "user_id": 42, + "tier": "pro", + "subscription_status": "pending_cancel", + "cancel_at_term_end": true, + "term_end": "2026-09-19T00:00:00+00:00", + "message": "Subscription will remain active with full quota through 2026-09-19, then move to Free." +} +``` +Behavior: sets `cancel_at_term_end=1`. Does **not** change `tier`, `subscription_status` +(stays `active` until term boundary, per the "not immediate lock" constraint — this endpoint sets +`subscription_status='pending_cancel'` purely as a display flag, tier/quota enforcement is +unaffected until term_end passes), does not touch `submissions_used_this_term`. The customer keeps +full remaining quota for the rest of the paid term. At `term_end`, the same lazy-expiry or +cron-sweep mechanism from a1 flips `tier='free'`, `subscription_status='expired'`, +`cancel_at_term_end=0`. + +**`POST /api/verdicttank/admin/reactivate-subscription`** (staff Bearer, companion undo) +```json +// Request +{"user_id": 42} +// Response 200 +{"user_id": 42, "subscription_status": "active", "cancel_at_term_end": false} +``` +Lets staff undo a pending cancellation before term_end (e.g. customer called back). Not explicitly +requested but a near-zero-cost companion to a2 that avoids a support dead-end; flag as optional. + +### b1. Client — Contact VerdictTank form + +**`POST /api/verdicttank/contact`** (public; Bearer optional to attach `user_id`/pre-fill) +```json +// Request +{ + "name": "Jane Prospect", + "email": "jane@example.com", + "subject": "Question about Enterprise seats", + "message": "..." +} +// Response 200 +{"message": "Thanks — we've received your message and will reply within one business day."} +// Response 422 (missing/invalid fields) +{"detail": "A valid email address is required."} +``` +Behavior: (1) sends an email via the existing `send_email()`/`smtplib` helper — sender +`noreply@verdicttank.com`, recipient `support@verdicttank.com`, subject prefixed +`[VerdictTank Contact] {subject}`, body includes name/email/message/user_id-if-known; (2) inserts +a row into `ops_notifications` (`type='contact'`, `payload` = the request JSON + resolved +`user_id`) so it also surfaces in the ops portal without depending on email deliverability. Note: +current SMTP config (`VERDICTTANK_SMTP_FROM`) defaults to `noreply@itpropartner.com`, not +`noreply@verdicttank.com` — confirm the `verdicttank.com` domain has SPF/DKIM/relay authorization +for that From address before wiring, or the send will fail/land in spam (see Constraints note in +Section 6). + +**`GET /api/verdicttank/admin/notifications`** (staff Bearer, ops portal display for b1) +```json +// Response 200 +{"notifications": [{"id": 1, "type": "contact", "payload": {...}, "created_at": "...", "read_at": null}], "total": 1} +``` + +**`POST /api/verdicttank/admin/notifications/{id}/read`** (staff Bearer) +```json +// Response 200 +{"id": 1, "read_at": "2026-08-19T14:02:00+00:00"} +``` + +### b2 / b3. Client — subscription/quota state (remaining submissions, renewal date, paid extras) + +**`GET /api/verdicttank/subscription`** (Bearer required) +```json +// Response 200 — Pro example +{ + "tier": "pro", + "tier_label": "Pro Review", + "subscription_status": "active", + "cancel_at_term_end": false, + "term_start": "2026-08-01T00:00:00+00:00", + "term_end": "2026-09-01T00:00:00+00:00", + "included_per_term": 8, + "submissions_used_this_term": 5, + "included_remaining": 3, + "extra_submissions_granted": 2, + "extra_submissions_used": 1, + "extra_remaining": 1, + "total_remaining": 4, + "overage_price_each": 18, + "is_demo": false, + "demo_expires_at": null +} +// Response 200 — Free example +{ + "tier": "free", + "tier_label": "Free Assessment", + "subscription_status": "active", + "cancel_at_term_end": false, + "term_start": null, + "term_end": null, + "included_per_term": 1, + "submissions_used_this_term": 1, + "included_remaining": 0, + "extra_submissions_granted": 0, + "extra_submissions_used": 0, + "extra_remaining": 0, + "total_remaining": 0, + "overage_price_each": null, + "is_demo": false, + "demo_expires_at": null +} +``` +This single endpoint backs both b2 (remaining + renewal date = `term_end`) and b3 (paid additional +submissions = `extra_submissions_granted`/`extra_submissions_used`/`extra_remaining`). The client +portal's dashboard view calls this alongside the existing `/reviews` call and renders a quota card. + +### Quota-blocking on submit (implicit, not a new endpoint — extends existing `/submit`) +`POST /api/verdicttank/submit` gains a pre-check: if the caller is authenticated and +`total_remaining <= 0`, return `403` with: +```json +{"detail": "You've used all included and extra submissions for this term. Contact us to add more."} +``` +Anonymous (no-Bearer) submissions are unaffected by quota (today's behavior — tier is +self-reported and unenforced for guests; enforcing quota requires an account, so this contract +does not change the current guest-checkout path, only the authenticated path). + +--- + +## 6. QUOTA ENFORCEMENT RULES + +### 6.1 Included quota by tier (authoritative table, restated) +| Tier | Included/term | Term length | Overage/each | +|---|---|---|---| +| free | 1 | lifetime (no reset, ever) | not allowed (hard stop) | +| oneshot | 1 | one-time (no reset, ever) | not allowed (hard stop) | +| pro | 8 | 1 month | $18 | +| enterprise | 50 | 1 month | $16 | +| whitelabel | 100 | 1 month | $28 | + +### 6.2 Remaining-submissions formula +``` +included_remaining = max(0, included_per_term(tier) - submissions_used_this_term) +extra_remaining = max(0, extra_submissions_granted - extra_submissions_used) +total_remaining = included_remaining + extra_remaining +``` +For `free`/`oneshot`, `included_per_term` is a fixed lifetime cap of 1 and `term_end` is always +NULL — there is no rollover, so `included_remaining` only ever goes from 1 to 0 and never resets. + +### 6.3 Consumption order and counter updates (on a successful `/submit` by an authenticated user) +1. Compute `total_remaining` as above. If `<= 0`, reject with 403 (see Section 5). +2. If `included_remaining > 0`: increment `submissions_used_this_term` by 1 (consume included + quota first). +3. Else (included exhausted, extra available): increment `extra_submissions_used` by 1 (consume + paid/manual extras second). +4. Anonymous/guest submissions (no Bearer) are NOT counted against any user's quota — they have no + `user_id` to attribute to, matching current behavior where `tier` is self-reported and + unenforced for guests. + +### 6.4 Term rollover (monthly tiers: pro/enterprise/whitelabel) +On any authenticated request that reads or mutates quota (submit, `/subscription`), first check: +`if now >= term_end: ` then roll the term forward: +``` +new_term_start = term_end +new_term_end = term_end + 1 month +submissions_used_this_term = 0 +extra_submissions_granted = 0 -- extras do NOT carry over; each term's manual grants are scoped +extra_submissions_used = 0 -- to that term, consistent with "no billing exists to track a running balance" +``` +Then, if `cancel_at_term_end == 1` at the moment of rollover: instead of rolling forward, downgrade +— `tier='free'`, `subscription_status='expired'`, `cancel_at_term_end=0`, `term_start=NULL`, +`term_end=NULL`. This is the actual mechanical trigger for a2's "effective at end of term": the +cancellation flag is inert until the term boundary is crossed, at which point the same rollover +check that would normally renew the term instead terminates it. This makes rollover/expiry a +**pure function of "did a request happen after term_end", not a background job** — matching the +lazy-expiry approach recommended in Section 5/a1 and requiring no new systemd timer. + +### 6.5 Demo accounts (a1) +Independent of tier quota — a demo account still has a real `tier` (e.g. "pro") and consumes quota +normally per 6.2/6.3, but additionally has `demo_expires_at`. On any request, if +`is_demo==1 AND now >= demo_expires_at`: downgrade identically to the cancellation end-of-term path +(`tier='free'`, `subscription_status='expired'`, `is_demo=0`, `demo_expires_at=NULL`) — a demo's +expiration is immediate/hard (not "end of term" like a real cancellation), since a demo was never a +paid commitment. + +### 6.6 Marketing site consistency (c1) +The `#pricing` section text must not contradict the enforced numbers above — in particular, the +current live copy calls Pro "Unlimited submissions and revisions for one active proposal at a +time," which directly conflicts with the 8/month quota. Section c1's job is to replace that claim +with the actual per-tier count (Free: 1 review; One-Shot: 1 review; Pro: 8/month; Enterprise: +50/month; White-Label: 100/month) so the enforced backend and the marketing promise match. + +--- + +## 7. KNOWN RECEIPT DEFECTS (confirmed exact lines, `/opt/verdicttank/api.py`, `generate_receipt()`) + +1. **Line 892** — `
{submission["submitted_at"][:19].replace("T", " ")}
` — + prints raw UTC with no timezone label or conversion (e.g. "2026-08-19 14:02:00" with no "UTC" + suffix), so an Eastern-time submitter sees what looks like their own local time but is 4-5 hours + off. Fix: append explicit `" UTC"` suffix (safest, no timezone-conversion risk) or convert to a + configured business timezone with a labeled abbreviation. +2. **Lines 897-905** — the "What Happens Next" card hardcodes the full panel description + ("Multi-Seat Panel — Independent reasoning seats from separate providers review your proposal + in a single pass, scoring ten dimensions") on every receipt regardless of tier. Per + `references/subscription-and-receipt.md` and `api-and-auth.md`, Free tier actually runs a + reduced 4-seat panel (research + primary + legal + financial), not the 9-seat/10-dimension + panel. Fix: branch this block on `submission["tier"] == "free"` and use tier-accurate seat/ + dimension counts in both branches. +3. **Line 891** — `
{label} — {price}
` — em dash between tier label and + price. **Line 908** — `VerdictTank — a product of IT Pro Partner` — em dash in + footer. Both violate the house style rule (no em/en dashes on VerdictTank surfaces). Fix: + replace `—` with a plain separator (e.g. `,` or `|`) in both lines; grep the whole + `generate_receipt()` function body for any other `—`/`–` before considering the fix complete + (a byte-level codepoint scan is safer than eyeballing, per the platform skill's pitfall notes). + +No other defects in `generate_receipt()` were found on this pass; the QUEUED badge and 4-step +copy layout are otherwise accurate to the current submit-flow behavior. + +--- + +## Open items requiring a decision before implementation (not assumptions made in this contract) + +- Whether the invite LINK's own expiry should equal the demo account's expiry X, or be a shorter + fixed window (e.g. 7 days to redeem) independent of the demo lifetime once redeemed (Section 5/a1). +- Whether `noreply@verdicttank.com` has outbound send authorization (SPF/DKIM/relay credentials) + distinct from the currently configured `noreply@itpropartner.com` — b1 assumes it does per the + task's stated sender, but the live `VERDICTTANK_SMTP_FROM` env default is the itpropartner.com + address; this needs its own SMTP credential/domain verification, not a code change. +- Whether extra-submission grants (b3) should be a simple counter (as modeled here) or a full + ledger table (`submission_grants`, also proposed in Section 4) for audit purposes — the schema + proposes both so either can be adopted without a second migration. diff --git a/demo-case-harvestlink-v2.md b/demo-case-harvestlink-v2.md new file mode 100644 index 0000000..ae0915d --- /dev/null +++ b/demo-case-harvestlink-v2.md @@ -0,0 +1,69 @@ +# VerdictTank Demo Case: HarvestLink (Revised) + +Revised after panel feedback. Same fictional company; changes are real responses to the +first review's fatal flaws: corrected market sizing, added pilot unit economics, a worked +pricing waterfall, a compliance plan, and a reframed Sacramento-only ask. + +--- + +## HarvestLink: Direct Farm-to-Restaurant Sourcing + +### The Problem +Independent restaurants lose roughly 30% of their produce spend to spoilage and distributor +markup. Small and mid-size farms have no efficient channel to reach restaurants directly, so +they sell through aggregators that take the margin. + +### The Solution +HarvestLink is a marketplace and logistics platform connecting local farms directly to +independent restaurants. Restaurants order through a mobile app; farms fulfill through a shared +cold-chain delivery network. Built-in demand forecasting tells farms what to plant and +restaurants what to order, cutting waste on both sides. + +### Market (corrected) +US independent restaurants spend an estimated $28 billion a year on fresh produce. Our three +launch metros (Sacramento, Austin, Portland) hold roughly 7.3 million people, about 2.2% of the +US population, which implies a served market near $620 million, not the $1.9 billion we +previously stated. Bottom-up check: about 4,800 independent restaurants across the three metros +at an average $130,000 a year in produce spend lands on the same ~$620 million figure. + +### Business Model (with pricing waterfall) +8% take rate on marketplace transactions plus a $99/month restaurant subscription after a +60-day free trial. Farms sell at a 12-18% premium over their farm-gate wholesale price, while +restaurants still pay less than distributor pricing. + +Worked waterfall on a $3,000/month produce basket: +- Distributor today: $2,308 wholesale x 30% markup = $3,000. +- HarvestLink: $2,308 wholesale x 12% farm premium = $2,585, plus 8% take = $2,792. +- Restaurant pays $2,792, saving $208/month before the $99 subscription; net ~$109/month after. + +### Traction and Unit Economics (Sacramento pilot, actual) +12 farms and 9 restaurants live. $18,400 GMV in three months, 71% repeat order rate. Two +restaurants churned over delivery windows. +- Average order value: $240. +- Order frequency: 2.8 orders per restaurant per month. +- GMV per restaurant: $681/month. +- Take revenue: 8% x $18,400 = $1,472 for the quarter; subscription is $0 during trials. +- Delivery: shared cold-chain routes at $28/stop today. Take revenue alone ($19/stop) does not + cover delivery; the $99 subscription is what makes a route contribution-positive. Route + break-even is roughly 45-60 restaurants per route at current AOV and frequency. We are at 9, + so the plan is Sacramento-only density before any expansion. + +### Compliance Plan +PACA license, FSMA/cold-chain HACCP food-safety procedures, cargo and liability insurance, +written supplier and restaurant terms, and a recall procedure before scaling beyond the pilot. +Multistate compliance is deferred: we are Sacramento-only for this round, so California-only +compliance applies. + +### Team and Hiring +Founder with seven years in restaurant operations and supply chain at a regional chain, plus one +contract engineer. This round funds a full-time logistics/operations lead and a sales hire; +both are prerequisites before any second-city launch. + +### The Ask +$250,000 to run a 12-month Sacramento-only proof: grow 9 to 60 restaurants, hire the +logistics and sales leads, lock in a cold-chain partner, and reach route-level contribution +breakeven. Austin and Portland are deferred until Sacramento breakeven is proven. + +### Use of Funds +45% logistics and delivery network, 30% sales and onboarding, 15% engineering, 10% compliance +and operations. diff --git a/demo-case-harvestlink.md b/demo-case-harvestlink.md new file mode 100644 index 0000000..b014cd7 --- /dev/null +++ b/demo-case-harvestlink.md @@ -0,0 +1,42 @@ +# VerdictTank Demo Case: HarvestLink + +Staged proposal for the VerdictTank product-marketing video. Fictional company, no real +people, no PII. Intentionally a "typical first-draft" with realistic texture and gaps so the +panel has real material to score. + +--- + +## HarvestLink: Direct Farm-to-Restaurant Sourcing + +### The Problem +Independent restaurants lose roughly 30% of their produce spend to spoilage and distributor +markup. Small and mid-size farms have no efficient channel to reach restaurants directly, so +they sell through aggregators that take the margin. + +### The Solution +HarvestLink is a marketplace and logistics platform connecting local farms directly to +independent restaurants. Restaurants order through a mobile app; farms fulfill through a shared +cold-chain delivery network. Built-in demand forecasting tells farms what to plant and +restaurants what to order, cutting waste on both sides. + +### Market +US independent restaurants spend an estimated $28 billion a year on fresh produce. Our three +launch cities (Sacramento, Austin, Portland) represent a $1.9 billion served market. + +### Business Model +8% take rate on marketplace transactions plus a $99/month restaurant subscription after a +60-day free trial. Farms sell at a 12-18% premium over wholesale by cutting out the distributor. + +### Traction +12 pilot farms and 9 restaurants live in Sacramento. $18,400 GMV in three months, 71% repeat +order rate. Two restaurants churned over delivery windows. + +### Team +Founder with seven years in restaurant operations and supply chain at a regional chain. One +contract engineer. No full-time hires yet. + +### The Ask +$500,000 seed to build the logistics layer, hire a head of sales, and launch Austin and Portland. + +### Use of Funds +40% engineering, 30% sales and onboarding, 20% cold-chain logistics partners, 10% operations. diff --git a/ops-portal/CONTRACT.md b/ops-portal/CONTRACT.md new file mode 100644 index 0000000..0c026d8 --- /dev/null +++ b/ops-portal/CONTRACT.md @@ -0,0 +1,125 @@ +# 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. diff --git a/ops-portal/index.html b/ops-portal/index.html new file mode 100644 index 0000000..6c610ca --- /dev/null +++ b/ops-portal/index.html @@ -0,0 +1,1060 @@ + + + + + +VerdictTank Staff Ops + + + + + + + + +
+ +
+ + + + + + + diff --git a/portal/index.html b/portal/index.html new file mode 100644 index 0000000..cd9fdcb --- /dev/null +++ b/portal/index.html @@ -0,0 +1,889 @@ + + + + + +VerdictTank | Client Portal + + + + + + +
+
+ + + + + + + + + + + + + +
+
+ + + + + +