Files
dre/operations/architecture-2026-08-21.md
root be0750d001 DRE customer portal: FastAPI + SQLite backend (18 endpoints)
- Magic-link auth (sha256-only token storage, 15-min single-use, 7-day sessions)
- Staff-key auth via X-DRE-Staff-Key (constant-time compare)
- SQLite WAL, foreign_keys, parameterized queries, atomic DRE/CLT sequence allocation
- Intake validator rejects SSN/PAN patterns (FDCPA/TDCPA compliance)
- Document upload allowlist + magic-byte check, 20MB cap
- Unified error envelope, money as integer cents
- systemd unit (port 8093, User=root, hardening directives)
- Fixes import bug (auth.py relative imports) and audit_log placeholder mismatch
2026-08-21 18:43:33 -04:00

41 KiB

DRE Customer Portal — Backend Architecture Specification

Author: Claude Opus 4-8 (System Architect) Date: 2026-08-21 Status: BUILD-READY — hand off to GLM-5.2 (backend) + Sonnet 5 (frontend) Stack: FastAPI + SQLite (single source of truth), magic-link auth, systemd + uvicorn behind Caddy


0. Scope & Principles

This spec defines the FIRST production backend for DRE. It replaces the 12 static mockups' dead <form> with a live intake pipeline and adds a magic-link customer portal.

In scope: self-serve claim intake → creates client + claim → emails DRE team → returns claim number; email magic-link auth (no passwords); customer portal (claim list/detail, document upload, messaging); internal staff read/write endpoints (staff-key auth) that back the existing dashboards.

Out of scope (fast-follow, do NOT block): TwentyCRM sync, DocuSeal LPOA wiring, Stripe, AI scoring, LetterStream, RON. Schema carries a nullable twentycrm_id on every synced entity so a later one-way push is clean.

Non-negotiable compliance:

  • NEVER collect/store SSNs, full bank account numbers, or card data. No column exists for them; the intake validator rejects any field that pattern-matches a 9-digit SSN or a 13-19 digit PAN.
  • All debtor + client data is PII. HTTPS only (Caddy terminates TLS). Secrets via env only.
  • Store only what recovery needs (contract/invoice metadata + uploaded docs).

Core conventions (locked, from platform spec):

  • Claim number: DRE-YYYY-NNNN (per-year sequence, zero-padded to 4).
  • Client ID: CLT-YYYY-NNNN (per-year sequence, zero-padded to 4).
  • Both generated server-side on first submission. Sequences are per calendar year.

1. SQLite Schema

DB file: /opt/dre-portal/data/dre.db Pragmas (set on every connection): PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;

All timestamps are ISO-8601 UTC strings (YYYY-MM-DDTHH:MM:SSZ), stored as TEXT. All monetary amounts stored as INTEGER cents (never float). All primary keys are TEXT UUID4 unless noted.

-- ============================================================
-- clients : one row per customer account (the creditor / claimant)
-- ============================================================
CREATE TABLE clients (
    id                TEXT PRIMARY KEY,              -- uuid4
    client_number     TEXT UNIQUE NOT NULL,          -- CLT-YYYY-NNNN
    company_name      TEXT NOT NULL,
    contact_name      TEXT NOT NULL,
    email             TEXT UNIQUE NOT NULL,          -- lowercased; magic-link identity
    phone             TEXT,
    tos_accepted_at   TEXT,                          -- set when ToS accepted at intake
    twentycrm_id      TEXT,                          -- nullable; set by future CRM sync
    created_at        TEXT NOT NULL,
    updated_at        TEXT NOT NULL
);
CREATE INDEX idx_clients_email ON clients(email);

-- ============================================================
-- debtors : the party the money is owed by (denormalized per claim is avoided;
--           one debtor row, referenced by claims). Minimal PII.
-- ============================================================
CREATE TABLE debtors (
    id                TEXT PRIMARY KEY,              -- uuid4
    name              TEXT NOT NULL,                 -- business or individual name
    business_type     TEXT NOT NULL DEFAULT 'OTHER'  -- enum below
                      CHECK (business_type IN
                        ('INDIVIDUAL','SOLE_PROPRIETORSHIP','LLC','CORPORATION','PARTNERSHIP','OTHER')),
    contact_email     TEXT,
    contact_phone     TEXT,
    physical_address  TEXT,                          -- free-text single line; NOT named "address"
    twentycrm_id      TEXT,                          -- nullable
    created_at        TEXT NOT NULL,
    updated_at        TEXT NOT NULL
);

-- ============================================================
-- claims : the collection case. Belongs to one client, one debtor.
-- ============================================================
CREATE TABLE claims (
    id                  TEXT PRIMARY KEY,            -- uuid4
    claim_number        TEXT UNIQUE NOT NULL,        -- DRE-YYYY-NNNN
    client_id           TEXT NOT NULL REFERENCES clients(id) ON DELETE RESTRICT,
    debtor_id           TEXT NOT NULL REFERENCES debtors(id) ON DELETE RESTRICT,
    amount_cents        INTEGER NOT NULL CHECK (amount_cents > 0),
    currency            TEXT NOT NULL DEFAULT 'USD',
    status              TEXT NOT NULL DEFAULT 'NEW'  -- lifecycle enum below
                        CHECK (status IN
                          ('NEW','UNDER_REVIEW','ACTIVE','NEGOTIATION','LEGAL','SETTLED','CLOSED','WRITE_OFF','REJECTED')),
    tier                TEXT NOT NULL DEFAULT 'TIER_1'
                        CHECK (tier IN ('TIER_1','TIER_2','TIER_2_5','TIER_3','TIER_4')),
    description         TEXT,                         -- what the debt is for (invoice desc, service)
    client_reference    TEXT,                         -- customer's own invoice/PO number
    invoice_date        TEXT,                         -- ISO date; when debt originated
    date_assigned       TEXT,                         -- set when moved out of NEW
    date_resolved       TEXT,                         -- set on SETTLED/CLOSED/WRITE_OFF
    twentycrm_id        TEXT,                         -- nullable
    created_at          TEXT NOT NULL,
    updated_at          TEXT NOT NULL
);
CREATE INDEX idx_claims_client ON claims(client_id);
CREATE INDEX idx_claims_status ON claims(status);
CREATE INDEX idx_claims_debtor ON claims(debtor_id);

-- ============================================================
-- documents : uploaded evidence, stored on disk; row holds metadata only
-- ============================================================
CREATE TABLE documents (
    id                TEXT PRIMARY KEY,              -- uuid4
    claim_id          TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
    original_name     TEXT NOT NULL,                 -- sanitized display name
    stored_path       TEXT NOT NULL,                 -- absolute path on disk (uuid-named)
    mime_type         TEXT NOT NULL,
    size_bytes        INTEGER NOT NULL,
    sha256            TEXT NOT NULL,                 -- integrity + dedupe
    uploaded_by       TEXT NOT NULL DEFAULT 'CLIENT' -- CLIENT | STAFF
                      CHECK (uploaded_by IN ('CLIENT','STAFF')),
    twentycrm_id      TEXT,                          -- nullable
    created_at        TEXT NOT NULL
);
CREATE INDEX idx_documents_claim ON documents(claim_id);

-- ============================================================
-- case_notes : messages + internal notes on a claim (threaded log)
--   visibility controls whether the client can see it in the portal.
-- ============================================================
CREATE TABLE case_notes (
    id                TEXT PRIMARY KEY,              -- uuid4
    claim_id          TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
    author_type       TEXT NOT NULL                 -- who wrote it
                      CHECK (author_type IN ('CLIENT','STAFF','SYSTEM')),
    author_name       TEXT NOT NULL,                -- display name (client contact, staff name, 'System')
    subject           TEXT,                          -- for client->team structured messages
    content           TEXT NOT NULL,                -- plaintext; rendered escaped (see security)
    visibility        TEXT NOT NULL DEFAULT 'SHARED' -- SHARED = client sees it; INTERNAL = staff only
                      CHECK (visibility IN ('SHARED','INTERNAL')),
    twentycrm_id      TEXT,                          -- nullable
    created_at        TEXT NOT NULL
);
CREATE INDEX idx_notes_claim ON case_notes(claim_id);

-- ============================================================
-- auth_tokens : single-use magic-link tokens
-- ============================================================
CREATE TABLE auth_tokens (
    id                TEXT PRIMARY KEY,              -- uuid4
    client_id         TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
    token_hash        TEXT UNIQUE NOT NULL,          -- sha256 of the raw token (raw never stored)
    expires_at        TEXT NOT NULL,                 -- created_at + 15 min
    consumed_at       TEXT,                          -- set on successful verify; NULL = unused
    requested_ip      TEXT,                          -- for rate-limit audit
    created_at        TEXT NOT NULL
);
CREATE INDEX idx_tokens_hash ON auth_tokens(token_hash);
CREATE INDEX idx_tokens_client ON auth_tokens(client_id);

-- ============================================================
-- sessions : bearer session tokens issued after magic-link verify
-- ============================================================
CREATE TABLE sessions (
    id                TEXT PRIMARY KEY,              -- uuid4
    client_id         TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
    session_hash      TEXT UNIQUE NOT NULL,          -- sha256 of the raw session token
    expires_at        TEXT NOT NULL,                 -- created_at + 7 days (sliding not required v1)
    revoked_at        TEXT,
    created_at        TEXT NOT NULL,
    last_seen_at      TEXT
);
CREATE INDEX idx_sessions_hash ON sessions(session_hash);

-- ============================================================
-- audit_log : append-only trail for claim/status changes (compliance)
-- ============================================================
CREATE TABLE audit_log (
    id                TEXT PRIMARY KEY,              -- uuid4
    entity_type       TEXT NOT NULL,                -- 'claim' | 'client' | 'document' | 'note'
    entity_id         TEXT NOT NULL,
    action            TEXT NOT NULL,                -- 'create' | 'status_change' | 'update' | 'upload' | 'note_add'
    field             TEXT,                          -- changed field name (nullable)
    old_value         TEXT,
    new_value         TEXT,
    actor             TEXT NOT NULL,                -- staff email/name, client_number, or 'system'
    reason            TEXT,
    created_at        TEXT NOT NULL
);
CREATE INDEX idx_audit_entity ON audit_log(entity_type, entity_id);

-- ============================================================
-- number_sequences : per-year counters for claim/client numbers
--   (avoids race by using an atomic UPDATE...RETURNING in a txn)
-- ============================================================
CREATE TABLE number_sequences (
    prefix            TEXT NOT NULL,                -- 'DRE' | 'CLT'
    year              INTEGER NOT NULL,
    last_value        INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (prefix, year)
);

1.1 Claim lifecycle status enum (authoritative)

Value Meaning Client sees
NEW Just submitted via intake; awaiting DRE review "Received"
UNDER_REVIEW DRE reviewing docs / AI analysis "Under Review"
ACTIVE Approved; recovery in progress (tier drives the sub-stage) "In Progress"
NEGOTIATION Debtor engaged; settlement talks "In Negotiation"
LEGAL Referred to partner law firm (Tier 4) "Legal Action"
SETTLED Payment received / agreed; disbursement pending "Settled"
CLOSED Fully resolved & disbursed; binders generated "Closed — Recovered"
WRITE_OFF Uncollectible; case closed without recovery "Closed — Uncollectible"
REJECTED DRE declined the claim at intake review "Not Accepted"

Tier enum: TIER_1 (Soft Touch), TIER_2 (Formal Demand), TIER_2_5 (Lien Threat), TIER_3 (Escalation), TIER_4 (Legal Action). Tier is independent of status: a claim can be ACTIVE at any tier. The frontend 4-step progress bar maps TIER_1→TIER_4 (TIER_2_5 renders as a sub-badge on TIER_2).

Note on CRM enum divergence: TwentyCRM Claims.status uses NEW, ACTIVE, NEGOTIATION, LEGAL, SETTLED, CLOSED, WRITE_OFF and tier TIER_1/2/3. Our schema adds UNDER_REVIEW, REJECTED, TIER_2_5. The future sync layer maps these: UNDER_REVIEWNEW, REJECTEDCLOSED(+note), TIER_2_5TIER_2. This mapping is a sync-layer concern, not a schema constraint — build the schema as specified above. [CONDUCTOR DECISION #1 — see §7.]


2. REST API Contract

Base URL: https://portal.debtrecoveryexperts.com/api (Caddy reverse-proxies /api/* to localhost:8090; static HTML continues to be served by Caddy from /var/www/capabilities/).

Auth models:

  • Public — no auth (intake, magic-link request/verify).
  • ClientAuthorization: Bearer <session_token>. Resolves to a client_id; every claim query is scoped to that client. 401 if missing/invalid/expired.
  • StaffX-DRE-Staff-Key: <key> header, compared constant-time to env DRE_STAFF_KEY. 403 if absent/wrong. (v1 uses a single shared staff key; internal pages are already behind Cloudflare Access, so this is defense-in-depth, not the primary gate.)

Global conventions:

  • All request/response bodies are JSON (Content-Type: application/json) except document upload (multipart/form-data).
  • Errors: {"error": {"code": "<machine_code>", "message": "<human msg>"}} with appropriate HTTP status. Codes: validation_error, not_found, unauthorized, forbidden, rate_limited, payload_too_large, unsupported_media_type, conflict, internal_error.
  • Money in responses returned BOTH as amount_cents (int) and amount_display (e.g. "$15,000.00").
  • Timestamps returned as ISO-8601 UTC.

2.1 Health

GET /api/health — Public. → 200 {"status":"ok","time":"<iso>"}. No DB write.

2.2 Intake (public)

POST /api/intake — Public. Creates client (or reuses by email) + debtor + claim, writes a SYSTEM case note, emails the DRE team, returns the claim number. This is what debt-recovery.html posts to.

Request body:

{
  "client": {
    "company_name": "Acme Builders LLC",
    "contact_name": "Jane Doe",
    "email": "jane@acmebuilders.com",
    "phone": "512-555-0100"
  },
  "debtor": {
    "name": "Delinquent Corp",
    "business_type": "LLC",
    "contact_email": "ap@delinquent.com",
    "contact_phone": "214-555-0199",
    "physical_address": "100 Main St, Dallas, TX 75201"
  },
  "claim": {
    "amount_cents": 1500000,
    "description": "Unpaid invoices for framing subcontract",
    "client_reference": "INV-2048",
    "invoice_date": "2026-03-15"
  },
  "tos_accepted": true,
  "turnstile_token": "<optional; validated if TURNSTILE_SECRET set>"
}

Behavior:

  • Validate all fields (see §6). amount_cents > 0 and ≤ 100_000_000 ($1M cap; larger flagged validation_error[CONDUCTOR DECISION #2]). Reject if any free-text field matches an SSN or PAN regex.
  • If a client with this (lowercased) email exists, reuse it and update contact fields; else create a new client with a fresh CLT-YYYY-NNNN. tos_accepted must be true → set tos_accepted_at.
  • Always create a new debtor row + new claim (status=NEW, tier=TIER_1) with DRE-YYYY-NNNN.
  • Insert audit_log create rows; insert a SYSTEM/SHARED case note "Claim received."
  • Send email to dre@debtrecoveryexperts.com (team notification) via the germainebrown.com relay (mail.germainebrown.com:2525, per platform email pitfalls) with claim summary. Email send failure must NOT fail the request — log it, still return success ([CONDUCTOR DECISION #3]).
  • Fire-and-forget confirmation email to the client (optional v1).

Response 201:

{
  "claim_number": "DRE-2026-0001",
  "client_number": "CLT-2026-0001",
  "status": "NEW",
  "message": "Claim received. Our team will review and contact you shortly."
}

POST /api/auth/request — Public. Requests a login link.

{ "email": "jane@acmebuilders.com" }
  • Always returns 200 {"message":"If an account exists, a login link has been sent."} regardless of whether the email exists (no account enumeration).
  • If the email maps to a client: generate a 32-byte URL-safe random token, store only its sha256 in auth_tokens with expires_at = now + 15min, email the link to the client via the DRE relay: https://portal.debtrecoveryexperts.com/portal/verify?token=<raw>.
  • Rate limits: max 3 requests per email per 15 min AND max 10 per IP per hour → 429 rate_limited.

POST /api/auth/verify — Public. Exchanges a magic-link token for a session.

{ "token": "<raw token from email link>" }
  • Hash the token, look up an unconsumed, unexpired row. If none → 401 unauthorized.
  • Mark consumed_at, create a sessions row (7-day expiry), return the session token.
  • Response 200: { "session_token": "<raw>", "expires_at": "<iso>", "client": {"client_number":"CLT-2026-0001","company_name":"...","contact_name":"..."} }
  • Frontend stores session_token (localStorage or an HttpOnly cookie set by the backend — [CONDUCTOR DECISION #4: cookie vs bearer]; spec defaults to bearer in localStorage for simplicity, documented XSS mitigations in §6).

POST /api/auth/logout — Client. Revokes current session. → 200 {"message":"Logged out."}

GET /api/auth/me — Client. → 200 { client: {...}, claim_count: N }. Used by portal to confirm session on load.

2.4 Claims (client)

GET /api/claims — Client. Lists the caller's claims (newest first).

{ "claims": [
  { "claim_number":"DRE-2026-0001", "status":"ACTIVE", "status_label":"In Progress",
    "tier":"TIER_2", "amount_cents":1500000, "amount_display":"$15,000.00",
    "debtor_name":"Delinquent Corp", "created_at":"<iso>", "date_resolved":null }
]}

GET /api/claims/{claim_number} — Client. Full detail; 404 if not owned by caller (never leak existence of other clients' claims — return 404, not 403).

{
  "claim_number":"DRE-2026-0001", "status":"ACTIVE", "status_label":"In Progress",
  "tier":"TIER_2", "tier_step":2, "amount_cents":1500000, "amount_display":"$15,000.00",
  "description":"Unpaid invoices...", "client_reference":"INV-2048",
  "invoice_date":"2026-03-15", "date_assigned":"<iso>", "date_resolved":null,
  "debtor": { "name":"Delinquent Corp", "business_type":"LLC" },
  "documents": [ { "id":"...", "original_name":"invoice.pdf", "size_bytes":48210,
                   "mime_type":"application/pdf", "uploaded_by":"CLIENT", "created_at":"<iso>" } ],
  "notes": [ { "author_type":"STAFF", "author_name":"Anita", "subject":null,
               "content":"We've sent the first demand.", "created_at":"<iso>" } ]
}

Notes list returns only visibility='SHARED' rows for client callers. Debtor block excludes internal fields (contact/address hidden from client — [CONDUCTOR DECISION #5]; spec default: client sees debtor name + type only).

2.5 Documents

POST /api/claims/{claim_number}/documents — Client. multipart/form-data, field file.

  • Enforce: max 20 MB per file; allowed MIME/extensions .pdf .jpg .jpeg .png .doc .docx; verify magic bytes, not just extension. Reject others → 415 unsupported_media_type / 413 payload_too_large.
  • Store to /opt/dre-portal/data/uploads/<claim_id>/<uuid><ext> (mode 0640), compute sha256, insert documents row + audit + SYSTEM shared note "Document uploaded: ".
  • Response 201: the document metadata object.

GET /api/claims/{claim_number}/documents/{document_id} — Client or Staff. Streams the file with Content-Disposition: attachment. 404 if not owned (client) / not found (staff). Never serve uploads via Caddy static — always through this authenticated endpoint.

2.6 Messaging / case notes (client)

POST /api/claims/{claim_number}/messages — Client. Structured message to the team.

{ "subject": "New information about the debtor", "content": "They changed their address to..." }
  • subject must be one of the fixed options (validated): Question about my claim, New information about the debtor, Payment received / want to stop recovery, Update my contact info, Complaint or concern, Other.
  • Insert case_notes (author_type=CLIENT, visibility=SHARED), audit, email the DRE team.
  • Response 201: the created note object.

(Client reads notes via the claim-detail endpoint §2.4; no separate GET needed for v1.)

2.7 Internal staff endpoints (staff-key auth)

These back the existing internal dashboards (replace mock rows).

GET /api/staff/claims — Staff. All claims with filters: ?status=NEW&tier=TIER_2&q=<search>&limit=50&offset=0. Search matches claim_number, company_name, debtor_name. Returns claims joined with client + debtor summary + counts.

GET /api/staff/claims/{claim_number} — Staff. Full detail incl. INTERNAL notes, debtor contact fields, all documents, and audit trail.

PATCH /api/staff/claims/{claim_number} — Staff. Update status/tier and resolution dates.

{ "status":"ACTIVE", "tier":"TIER_2", "reason":"Docs approved, moving to formal demand" }
  • Validate enum values. On status change to a resolved state, set date_resolved; on first move out of NEW, set date_assigned. Write audit rows (old→new, actor=staff, reason). Optionally auto-add a SYSTEM/SHARED note so the client sees the status change. This is the write-back that flows to the client portal.
  • Response 200: updated claim detail.

POST /api/staff/claims/{claim_number}/notes — Staff. Add a note.

{ "content":"Called debtor, left VM", "visibility":"INTERNAL", "author_name":"Tony" }

visibility defaults INTERNAL; set SHARED to make it client-visible. Response 201.

POST /api/staff/claims/{claim_number}/documents — Staff. Same as client upload but uploaded_by=STAFF; may be marked to appear (or not) to client via a client_visible flag ([CONDUCTOR DECISION #6]; spec default: staff uploads are internal-only, not shown to client).

GET /api/staff/stats — Staff. Aggregate rollups for dashboard/analytics cards:

{
  "total_claims": 12, "by_status": {"NEW":3,"ACTIVE":5,"SETTLED":2,"CLOSED":2},
  "by_tier": {"TIER_1":4,"TIER_2":5,"TIER_3":3},
  "total_amount_cents": 42000000, "total_amount_display":"$420,000.00",
  "recovered_amount_cents": 12000000, "open_amount_cents": 30000000,
  "aging": { "over_30_days": 2, "over_60_days": 1 }
}

GET /api/staff/audit?entity_type=claim&entity_id=<id> — Staff. Audit trail for change history UI.

2.8 Endpoint summary table

Method Path Auth Purpose
GET /api/health Public Liveness
POST /api/intake Public Create client+debtor+claim
POST /api/auth/request Public Request magic link
POST /api/auth/verify Public Exchange token → session
POST /api/auth/logout Client Revoke session
GET /api/auth/me Client Session/account check
GET /api/claims Client List own claims
GET /api/claims/{claim_number} Client Own claim detail
POST /api/claims/{claim_number}/documents Client Upload document
GET /api/claims/{claim_number}/documents/{id} Client/Staff Download document
POST /api/claims/{claim_number}/messages Client Message the team
GET /api/staff/claims Staff All claims + filters
GET /api/staff/claims/{claim_number} Staff Full internal detail
PATCH /api/staff/claims/{claim_number} Staff Update status/tier (write-back)
POST /api/staff/claims/{claim_number}/notes Staff Add internal/shared note
POST /api/staff/claims/{claim_number}/documents Staff Staff upload
GET /api/staff/stats Staff Aggregate dashboard metrics
GET /api/staff/audit Staff Change history

Goal: passwordless, secure-by-default client login.

  1. Request. Client enters email on login.htmlPOST /api/auth/request {email}.
  2. Generate. Backend: if email matches a client, create token = secrets.token_urlsafe(32). Store ONLY sha256(token) in auth_tokens with expires_at = now + 15 minutes, consumed_at=NULL, requested_ip. Never store or log the raw token.
  3. Deliver. Email the client (via mail.germainebrown.com:2525, from dre@debtrecoveryexperts.com) a link: https://portal.debtrecoveryexperts.com/portal/verify?token=<raw>. Always respond 200 with a generic message (anti-enumeration).
  4. Click. The verify page reads token from the query string and calls POST /api/auth/verify {token}.
  5. Exchange. Backend hashes the token, finds a row that is unexpired AND unconsumed. If found: set consumed_at=now (single-use), create a sessions row (session_token=token_urlsafe(32), store sha256, expires_at = now + 7 days), return the raw session token + client summary.
  6. Authenticated calls. Frontend sends Authorization: Bearer <session_token> on every portal API call. Backend hashes it, looks up a non-revoked, unexpired session, resolves client_id, updates last_seen_at.
  7. Logout. POST /api/auth/logout sets revoked_at.

Security notes:

  • Tokens are 256-bit random (secrets), URL-safe. Only sha256 hashes are persisted → DB leak does not yield usable tokens.
  • Magic-link TTL 15 min; single-use (consumed on verify). Session TTL 7 days, revocable.
  • Constant-time comparison for hashes and the staff key (hmac.compare_digest).
  • Rate limit /api/auth/request (3/email/15min, 10/IP/hour) to stop link-spam / mailbox flooding.
  • No account enumeration: identical 200 response whether or not the email exists.
  • Verify page must POST the token (not GET-navigate to the API) so the raw token stays out of the API's access logs / Referer chains; the page strips ?token= from the URL after reading it.
  • Expired/consumed tokens are pruned by a lightweight sweep on each verify attempt (delete rows where expires_at < now - 1 day).

4. Frontend Page Inventory + Data Mapping

Existing files live in /var/www/capabilities/ (public) and /var/www/internal/ (staff). Sonnet 5 wires these to the API. New pages are flagged NEW.

Page (file) Location Auth Calls Displays / Action
debt-recovery.html (intake) public none POST /api/intake Wire the dead <form>: collect Your Info / Debtor Info / Claim Details, submit JSON, show returned claim number + confirmation. Optional Turnstile.
login.html public none POST /api/auth/request Add an email field + "Email me a login link" button. Replace/append to the SSO-only pattern. Show "check your email" state.
portal/verify (NEW) public none→client POST /api/auth/verify Reads ?token, exchanges for session, stores session token, redirects to client dashboard. Handles invalid/expired token error state.
dre-client-dashboard.html public* client GET /api/auth/me, GET /api/claims Replace empty-state/mock rows with real active + past claims, stat cards (count, recovered, open), tier progress bar. Requires session; redirect to login if 401.
portal/claim (NEW or extend dash) public* client GET /api/claims/{n}, POST .../documents, POST .../messages Claim detail: status/tier progress, document list + upload dropzone (real <input type=file>), shared notes thread, "message the team" form with fixed subjects.
dre-dashboard.html (internal) internal staff GET /api/staff/claims, PATCH /api/staff/claims/{n} Replace mock claim rows with live data; status/tier update controls that write back.
dre-case-aging.html internal staff GET /api/staff/claims (sort by age), GET /api/staff/stats Aging buckets from real created_at/date_assigned.
dre-analytics.html internal staff GET /api/staff/stats Replace static charts with real by_status / by_tier / recovered totals.
inbox.html internal staff (unchanged — IMAP poller JSON) Out of scope; keep as-is.
letter-queue.html internal staff (v1: unchanged; later reads GET /api/staff/claims) Not wired in v1.

* Client dashboard/claim pages are currently in the public docroot. Since they now require a session token (enforced by the API — every data call is 401 without a valid session), they can stay in /var/www/capabilities/; the pages themselves render an empty shell + "please log in" until the session resolves. [CONDUCTOR DECISION #7: keep client portal on portal. public docroot vs move behind its own path.] Spec default: keep in public docroot, gate by API session.

Frontend session handling: store the session token in localStorage under dre_session. Send as Authorization: Bearer. On any 401, clear it and redirect to login.html. (If Conductor picks HttpOnly cookies in Decision #4, backend sets Set-Cookie: dre_session=...; HttpOnly; Secure; SameSite=Lax and frontend drops the localStorage logic.)


5. Deployment Plan

Mirror the /opt/ops-portal pattern: venv + uvicorn under systemd, localhost port, Caddy in front.

5.1 Layout

/opt/dre-portal/
├── app/                      # FastAPI code (from GLM-5.2)
│   ├── main.py               # app + routers
│   ├── db.py                 # sqlite connection helper (pragmas), migrations runner
│   ├── schema.sql            # the CREATE TABLE block from §1
│   ├── auth.py, intake.py, claims.py, staff.py, email.py, ...
├── data/
│   ├── dre.db                # SQLite (WAL)
│   └── uploads/<claim_id>/   # uploaded docs, mode 0640
├── .env                      # secrets (mode 0600)
└── venv/                     # python venv

Code home for git is /root/projects/dre/ (repo). Deploy = git pull in the repo then rsync/symlink the app/ into /opt/dre-portal/app/ (or clone the repo directly into /opt/dre-portal and run from there — [CONDUCTOR DECISION #8: run-from-repo vs deploy-copy]; spec default: clone repo at /opt/dre-portal, data/ and .env gitignored).

5.2 Environment (/opt/dre-portal/.env, chmod 600)

DRE_STAFF_KEY=<64-hex random>
DRE_DB_PATH=/opt/dre-portal/data/dre.db
DRE_UPLOAD_DIR=/opt/dre-portal/data/uploads
DRE_BASE_URL=https://portal.debtrecoveryexperts.com
# Email relay (per platform email pitfalls — use germainebrown.com relay, NOT MXroute:587)
DRE_SMTP_HOST=mail.germainebrown.com
DRE_SMTP_PORT=2525
DRE_SMTP_FROM=dre@debtrecoveryexperts.com
DRE_TEAM_NOTIFY=dre@debtrecoveryexperts.com
DRE_SMTP_USER=<from ~/.hermes/.env DRE_EMAIL_* if relay auth required>
DRE_SMTP_PASS=<...>
TURNSTILE_SECRET=<optional; if unset, intake skips captcha check>

Reuse existing DRE_EMAIL_* creds from ~/.hermes/.env for SMTP if the relay needs auth.

5.3 systemd unit — /etc/systemd/system/dre-portal.service

[Unit]
Description=DRE Customer Portal API (FastAPI/uvicorn)
After=network.target

[Service]
Type=simple
WorkingDirectory=/opt/dre-portal
EnvironmentFile=/opt/dre-portal/.env
ExecStart=/opt/dre-portal/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8090
Restart=on-failure
RestartSec=3
# hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/dre-portal/data
ProtectHome=true

[Install]
WantedBy=multi-user.target

Enable: systemctl daemon-reload && systemctl enable --now dre-portal.

5.4 Caddy routing

Extend the existing portal.debtrecoveryexperts.com block so /api/* proxies to the app while static files continue to serve. Order matters — the handle /api/* must precede file_server.

portal.debtrecoveryexperts.com {
    handle /api/* {
        reverse_proxy localhost:8090
    }
    handle {
        root * /var/www/capabilities/
        try_files {path} {path}.html /index.html
        file_server
    }
}

pay. and internal. blocks unchanged. After edit: caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy. The app must NEVER be exposed on a public port — only 127.0.0.1:8090.

5.5 Init / migration steps

  1. python3 -m venv /opt/dre-portal/venv
  2. venv/bin/pip install fastapi uvicorn[standard] python-multipart (+ email-validator; stdlib sqlite3, secrets, hashlib, hmac, smtplib cover the rest — no ORM in v1).
  3. On first boot, db.py runs schema.sql if the DB is absent (idempotent CREATE TABLE IF NOT EXISTS), then seeds number_sequences for the current year if missing.
  4. Migrations: numbered SQL files in app/migrations/NNNN_*.sql, tracked in a schema_migrations table; runner applies unapplied ones on startup. (v1 ships with 0001_init.sql == schema.sql.)
  5. chown -R dre-portal:dre-portal /opt/dre-portal/data (or the service user); chmod 750 data, chmod 640 on the db file. [CONDUCTOR DECISION #9: dedicated service user vs run as existing ops user like ops-portal.] Spec default: reuse the ops-portal service user pattern.

6. Security & Edge Cases

Input validation (all endpoints):

  • Use Pydantic models for every request body; reject unknown fields (extra="forbid").
  • Email validated (email-validator), lowercased before storage/lookup.
  • amount_cents: positive int, ≤ 100_000_000 (see Decision #2). Reject non-integer / float.
  • business_type, status, tier, message subject validated against their fixed enums server-side.
  • String length caps: names ≤ 200, description ≤ 5000, note content ≤ 10000, address ≤ 500.
  • PII rejection: run every free-text intake field through SSN regex \b\d{3}-?\d{2}-?\d{4}\b and PAN regex \b(?:\d[ -]?){13,19}\b; if matched, reject with validation_error "Do not include Social Security or bank/card numbers." (Compliance-critical.)

SQL injection: ALL queries use parameterized statements (? placeholders / named params via sqlite3). NEVER f-string/format user input into SQL. Table/column names are never taken from input.

XSS in case notes / messages: store content as raw plaintext; the frontend renders it with textContent (never innerHTML) OR the backend returns an content_html that is HTML-escaped server-side. Spec: store raw, escape on output; API returns escaped content and the frontend inserts via textContent. No markdown/HTML allowed in v1. Subject is enum-only (no free text).

Magic-link rate limiting: in-process token-bucket / sliding-window counters keyed by email and by IP (backed by a small in-memory dict with periodic cleanup; acceptable for single-instance v1). 3/email/15min, 10/IP/hour on /api/auth/request; 10/IP/15min on /api/auth/verify (brute-force guard — though 256-bit tokens make guessing infeasible). Intake: 20/IP/hour + Turnstile if configured.

File upload:

  • Max 20 MB/file (enforced by reading Content-Length AND streaming with a hard byte cap).
  • Allowlist extensions + MIME + magic-byte sniff (python-magic optional; else check known signatures for PDF %PDF, JPEG FFD8, PNG 89504E47, ZIP-based docx 504B0304). Reject on mismatch.
  • Store OUTSIDE any web-served directory (/opt/dre-portal/data/uploads), uuid-named to prevent path traversal; never trust original_name for the path. Sanitize original_name for display.
  • Serve only via the authenticated download endpoint with Content-Disposition: attachment and a safe Content-Type (or application/octet-stream) to prevent inline execution.
  • Per-claim document count cap (e.g. 50) to prevent abuse.

AuthZ / data isolation: every client claim query filters by the session's client_id. Accessing another client's claim_number returns 404 (not 403) to avoid confirming existence. Staff key compared with hmac.compare_digest.

Transport & secrets: HTTPS enforced by Caddy (app only on localhost). Secrets only from .env; never logged. Redact tokens/keys from logs. Access logs must not contain the ?token= query value (verify uses POST).

Other edge cases:

  • Duplicate email at intake → reuse client, still create new claim (a client can have many claims).
  • Concurrent number generation → atomic UPDATE number_sequences SET last_value = last_value + 1 ... RETURNING last_value inside the same transaction as the insert; retry on the (rare) SQLite busy.
  • Year rollover → sequence keyed by (prefix, year); new year starts at 0001 automatically.
  • Email relay down → intake/messages still succeed (email is best-effort); failure logged + surfaced in audit_log as a note action so staff can follow up.
  • Clock/expiry → all comparisons in UTC; expired tokens/sessions rejected and lazily pruned.
  • Empty portal (new client, no claims) → endpoints return empty arrays; frontend shows empty state.

7. Open Decisions for the Conductor

# Decision Spec default (build this unless overridden)
1 Extra statuses (UNDER_REVIEW,REJECTED,TIER_2_5) diverge from TwentyCRM enums. Keep richer local enum? Yes — keep richer enum; sync layer maps down later.
2 Max claim amount cap. $1,000,000 (100_000_000 cents); larger → validation error.
3 Should intake fail if the team-notification email fails to send? No — email is best-effort; request still returns 201.
4 Session transport: Bearer token in localStorage vs HttpOnly cookie. Bearer in localStorage (simpler; XSS mitigated by textContent rendering).
5 Does the client see debtor contact/address in claim detail? No — client sees debtor name + type only.
6 Are staff-uploaded documents visible to the client? No — staff uploads internal-only by default.
7 Keep client dashboard/claim pages in public docroot (API-gated) or move behind a path? Keep in public docroot, gate by API session.
8 Deploy model: run FastAPI directly from the git repo clone at /opt/dre-portal, or copy app/ from /root/projects/dre? Clone repo at /opt/dre-portal; data/ + .env gitignored.
9 Service user: dedicated dre-portal user vs reuse ops-portal user. Reuse ops-portal service-user pattern.

Also flag to conductor (informational, not blocking):

  • TwentyCRM Payment→Claim and CaseNote→Claim relations are still missing (per current state). The future sync layer will need them; not required for this backend.
  • Turnstile secret not yet provisioned — intake ships with captcha check conditional on the env var, so it works with or without it.
  • LPOA/DocuSeal, Stripe, LetterStream, AI scoring are all explicitly deferred (fast-follow).

8. Handoff Notes

  • GLM-5.2 (backend): implement §1 schema verbatim, §2 endpoints, §3 auth, §5 deploy, §6 security. No ORM required — stdlib sqlite3 with parameterized queries + Pydantic for validation. Keep raw tokens out of the DB and logs. Reuse ~/.hermes/.env DRE_EMAIL_* creds for SMTP via the germainebrown.com relay.
  • Sonnet 5 (frontend): wire the pages per §4. Every data call sends Authorization: Bearer; render all user/staff text via textContent. Real <input type=file> dropzone (see platform pitfall). chmod 644 any new HTML in the webroots. Keep the D|R|E logo, nav, and theme conventions from the platform skill.
  • Both: the internal dashboards read from /api/staff/* with the X-DRE-Staff-Key header (still behind Cloudflare Access). Status changes via PATCH /api/staff/claims/{n} are the write-back that surfaces in the client portal.