- 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
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_REVIEW→NEW, REJECTED→CLOSED(+note),
TIER_2_5→TIER_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).
- Client —
Authorization: Bearer <session_token>. Resolves to aclient_id; every claim query is scoped to that client. 401 if missing/invalid/expired. - Staff —
X-DRE-Staff-Key: <key>header, compared constant-time to envDRE_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) andamount_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 flaggedvalidation_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_acceptedmust betrue→ settos_accepted_at. - Always create a new debtor row + new claim (
status=NEW,tier=TIER_1) withDRE-YYYY-NNNN. - Insert
audit_logcreate rows; insert aSYSTEM/SHAREDcase 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."
}
2.3 Magic-link auth (public)
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_tokenswithexpires_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 asessionsrow (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, insertdocumentsrow + audit +SYSTEMshared 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..." }
subjectmust 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 ofNEW, setdate_assigned. Write audit rows (old→new, actor=staff, reason). Optionally auto-add aSYSTEM/SHAREDnote 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 |
3. Magic-Link Auth Flow
Goal: passwordless, secure-by-default client login.
- Request. Client enters email on
login.html→POST /api/auth/request {email}. - Generate. Backend: if email matches a client, create
token = secrets.token_urlsafe(32). Store ONLYsha256(token)inauth_tokenswithexpires_at = now + 15 minutes,consumed_at=NULL,requested_ip. Never store or log the raw token. - Deliver. Email the client (via
mail.germainebrown.com:2525, fromdre@debtrecoveryexperts.com) a link:https://portal.debtrecoveryexperts.com/portal/verify?token=<raw>. Always respond200with a generic message (anti-enumeration). - Click. The
verifypage readstokenfrom the query string and callsPOST /api/auth/verify {token}. - Exchange. Backend hashes the token, finds a row that is unexpired AND unconsumed. If found:
set
consumed_at=now(single-use), create asessionsrow (session_token=token_urlsafe(32), storesha256,expires_at = now + 7 days), return the raw session token + client summary. - Authenticated calls. Frontend sends
Authorization: Bearer <session_token>on every portal API call. Backend hashes it, looks up a non-revoked, unexpired session, resolvesclient_id, updateslast_seen_at. - Logout.
POST /api/auth/logoutsetsrevoked_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
200response 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
python3 -m venv /opt/dre-portal/venvvenv/bin/pip install fastapi uvicorn[standard] python-multipart(+email-validator; stdlibsqlite3,secrets,hashlib,hmac,smtplibcover the rest — no ORM in v1).- On first boot,
db.pyrunsschema.sqlif the DB is absent (idempotentCREATE TABLE IF NOT EXISTS), then seedsnumber_sequencesfor the current year if missing. - Migrations: numbered SQL files in
app/migrations/NNNN_*.sql, tracked in aschema_migrationstable; runner applies unapplied ones on startup. (v1 ships with0001_init.sql== schema.sql.) chown -R dre-portal:dre-portal /opt/dre-portal/data(or the service user);chmod 750 data,chmod 640on 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, messagesubjectvalidated 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}\band PAN regex\b(?:\d[ -]?){13,19}\b; if matched, reject withvalidation_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-LengthAND streaming with a hard byte cap). - Allowlist extensions + MIME + magic-byte sniff (
python-magicoptional; else check known signatures for PDF%PDF, JPEGFFD8, PNG89504E47, ZIP-based docx504B0304). Reject on mismatch. - Store OUTSIDE any web-served directory (
/opt/dre-portal/data/uploads), uuid-named to prevent path traversal; never trustoriginal_namefor the path. Sanitizeoriginal_namefor display. - Serve only via the authenticated download endpoint with
Content-Disposition: attachmentand a safeContent-Type(orapplication/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_valueinside 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_logas anoteaction 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
sqlite3with parameterized queries + Pydantic for validation. Keep raw tokens out of the DB and logs. Reuse~/.hermes/.envDRE_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 viatextContent. Real<input type=file>dropzone (see platform pitfall).chmod 644any 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 theX-DRE-Staff-Keyheader (still behind Cloudflare Access). Status changes viaPATCH /api/staff/claims/{n}are the write-back that surfaces in the client portal.