build prospect intelligence platform MVP

This commit is contained in:
Marco0300
2026-09-02 17:38:50 +02:00
commit 44be4efc82
29 changed files with 973 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Local, non-secret runtime configuration. Copy to .env; never commit real credentials.
APP_ENV=development
LOG_LEVEL=INFO
API_PORT=8000
WEB_PORT=8080
CORS_ORIGINS=http://localhost:8080
# Deliberately fixed to false in compose for this MVP.
AUTOMATED_OUTREACH_ENABLED=false
+50
View File
@@ -0,0 +1,50 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
compose:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate Compose configuration
run: docker compose config
- name: Compile stdlib container code
run: python3 -m compileall -q apps/api apps/web
- name: Run API tests
run: python3 -m unittest discover -s apps/api/tests -t apps/api
- name: Build images
run: docker compose build
- name: Start services
run: docker compose up -d
- name: Wait for healthy services and smoke test
run: |
set -eu
for i in $(seq 1 30); do
api_status=$(docker inspect --format '{{.State.Health.Status}}' "$(docker compose ps -q api)")
web_status=$(docker inspect --format '{{.State.Health.Status}}' "$(docker compose ps -q web)")
if [ "$api_status" = healthy ] && [ "$web_status" = healthy ]; then
curl --fail http://localhost:8000/api/v1/health/live
curl --fail http://localhost:8080/healthz
exit 0
fi
sleep 2
done
docker compose ps
docker compose logs
exit 1
- name: Tear down
if: always()
run: docker compose down -v
+5
View File
@@ -0,0 +1,5 @@
.env
*.db
__pycache__/
*.pyc
.DS_Store
+63
View File
@@ -0,0 +1,63 @@
# Prospect Intelligence Platform
A safety-first MVP vertical slice for evidence-led prospect discovery and qualification. It stores normalized businesses, scores transparent opportunity signals, preserves reviewable fields, and blocks suppressed records. **Automated outreach is disabled.**
## Included
- Dependency-free Python/SQLite API under `apps/api`.
- Normalization, conservative website classification, exact deduplication, versioned scoring, suppression checks.
- JSON API under `/api/v1` for health, dashboard summary, businesses, suppression, and CSV-style import preview.
- Responsive static dashboard under `apps/web` with explorer filters, evidence/freshness labels, detail review, manual intake, and browser-only CSV preview.
- Docker Compose runtime with non-root containers, read-only filesystems, health checks, and a named SQLite data volume.
- Security and operations guidance in `docs/`.
## Run locally
```bash
cd apps/api
python3 -m unittest discover -v
python3 app/main.py --host 127.0.0.1 --port 8000 --db /tmp/prospects.db
```
Serve the UI separately:
```bash
cd apps/web
python3 -m http.server 8080
```
Open `http://127.0.0.1:8080`. The UI uses demo data by default; set `window.API_BASE` in the browser console to `http://127.0.0.1:8000` when testing the API locally.
## API smoke calls
```bash
curl http://127.0.0.1:8000/api/v1/health/live
curl http://127.0.0.1:8000/api/v1/businesses
curl -X POST http://127.0.0.1:8000/api/v1/businesses \
-H 'content-type: application/json' \
-d '{"name":"Example Plumbing","website":"https://example.invalid","email":"info@example.invalid","phone":"+27 21 555 0100"}'
```
## Compose
```bash
cp .env.example .env
docker compose config --quiet
docker compose up --build -d
curl -fsS http://localhost:8000/api/v1/health/live
curl -fsS http://localhost:8080/healthz
docker compose down
```
The initial pilot deliberately omits Postgres, Redis, Celery, external discovery adapters, DNS/HTTP scanning, authentication, and outbound messaging. Those are separate release gates from the implementation plan and must not be inferred from this MVP. Before production use, add real authentication/tenant isolation, migrations, SSRF-safe scanners, approved source registry, queue idempotency, backups/restore drills, legal review, and security testing.
## Verification
```bash
python3 -m unittest discover -v -s apps/api/tests -t apps/api
php -l /dev/null 2>/dev/null || true # no PHP application is used here
git diff --check
docker compose config --quiet
```
See `apps/api/README.md`, `apps/web/README.md`, `docs/SECURITY.md`, and `docs/OPERATIONS.md` for details.
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.13-alpine
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN addgroup -S app && adduser -S -G app app
WORKDIR /app
COPY app /app/app
COPY schema.sql /app/schema.sql
RUN mkdir -p /data && chown -R app:app /app /data
USER app
EXPOSE 8000
CMD ["python", "/app/app/main.py", "--host", "0.0.0.0", "--port", "8000", "--db", "/data/prospects.db"]
+27
View File
@@ -0,0 +1,27 @@
# Prospect Platform API MVP
Dependency-light JSON API for a single demo tenant (`demo-tenant`). Core domain rules use only Python's standard library; persistence is SQLite. This MVP stores prospect evidence and scores, but never sends outreach.
## Run
From this directory:
```bash
python3 app/main.py
# listens on http://127.0.0.1:8000
python3 -m unittest discover
```
Set `PROSPECT_API_PORT` or pass `--port`; set `PROSPECT_API_DB` or pass `--db` to override the default `prospects.db`.
## Endpoints
- `GET /api/v1/health/live` — liveness.
- `GET /api/v1/dashboard/summary` — tenant-scoped counts and score average.
- `GET /api/v1/businesses` — list businesses; optional `?q=` filter.
- `POST /api/v1/businesses` — create a business from JSON (`name` required; website, email, phone, description optional). Normalization, scoring, deduplication, and suppression are enforced server-side.
- `GET /api/v1/businesses/{id}` — retrieve one tenant-scoped business.
- `POST /api/v1/suppressions` — add `{kind: email|domain|phone, value: ...}`. Future matching business creation is blocked.
- `POST /api/v1/imports/preview` — preview `{rows: [...]}` without writing; reports accepted, duplicates, suppressed, normalized rows.
All SQL uses parameters and all responses are JSON. Scores include `score_version` and `score_factors` for traceability. There is intentionally no send/outreach endpoint.
View File
+95
View File
@@ -0,0 +1,95 @@
"""Pure, dependency-free prospect domain rules."""
from __future__ import annotations
import re
from urllib.parse import urlparse
SCORE_VERSION = "mvp-1"
_SOCIAL = {"facebook.com", "instagram.com", "linkedin.com", "twitter.com", "x.com", "youtube.com", "tiktok.com"}
def normalize_domain(value: str | None) -> str:
value = (value or "").strip().lower()
if not value:
return ""
parsed = urlparse(value if "://" in value else "//" + value)
host = (parsed.hostname or "").strip(".")
if host.startswith("www."):
host = host[4:]
return host
def normalize_phone(value: str | None) -> str:
return re.sub(r"[^0-9+]", "", (value or "").strip())
def normalize_business(raw: dict) -> dict:
name = " ".join(str(raw.get("name", "")).split())
email = str(raw.get("email", "")).strip().lower()
website = str(raw.get("website", "")).strip()
domain = normalize_domain(raw.get("website_domain") or website)
phone = normalize_phone(raw.get("phone"))
result = dict(raw)
result.update({"name": name, "email": email, "website": website, "website_domain": domain, "phone": phone})
return result
def classify_website(website_or_domain: str | None) -> str:
domain = normalize_domain(website_or_domain)
if not domain:
return "missing"
if any(domain == item or domain.endswith("." + item) for item in _SOCIAL):
return "social_profile"
return "business_site"
def score_business(business: dict) -> dict:
b = normalize_business(business)
score = 0
factors = []
if b.get("name"):
score += 20; factors.append("named_business")
site_class = classify_website(b.get("website_domain") or b.get("website"))
if site_class == "business_site":
score += 30; factors.append("business_site")
if b.get("email"):
score += 25; factors.append("email")
if b.get("phone"):
score += 15; factors.append("phone")
if b.get("description"):
score += 10; factors.append("description")
return {"score": min(score, 100), "score_version": SCORE_VERSION, "factors": factors, "website_class": site_class}
def suppression_values(business: dict) -> set[str]:
b = normalize_business(business)
return {x for x in (b.get("email"), b.get("website_domain"), b.get("phone")) if x}
def is_suppressed(business: dict, suppressions: list[dict]) -> bool:
values = suppression_values(business)
for item in suppressions:
kind, value = item.get("kind", ""), str(item.get("value", "")).strip().lower()
if kind == "domain": value = normalize_domain(value)
elif kind == "phone": value = normalize_phone(value)
if value and value in values:
return True
return False
def deduplication_key(business: dict) -> tuple[str, str]:
b = normalize_business(business)
if b["website_domain"]: return ("domain", b["website_domain"])
if b["email"]: return ("email", b["email"])
if b["phone"]: return ("phone", b["phone"])
return ("name", re.sub(r"[^a-z0-9]", "", b["name"].lower()))
def deduplicate_businesses(rows: list[dict]) -> list[dict]:
chosen: dict[tuple[str, str], dict] = {}
for raw in rows:
item = normalize_business(raw)
key = deduplication_key(item)
if key not in chosen or sum(bool(v) for v in item.values()) > sum(bool(v) for v in chosen[key].values()):
chosen[key] = item
return list(chosen.values())
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
import argparse
import json
import os
import sqlite3
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business
else:
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business
ORGANIZATION_ID = "demo-tenant"
SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
def connect(db_path: str) -> sqlite3.Connection:
db = sqlite3.connect(db_path)
db.row_factory = sqlite3.Row
db.execute("PRAGMA foreign_keys = ON")
db.executescript(SCHEMA.read_text())
return db
def row_json(row: sqlite3.Row) -> dict:
result = dict(row)
result["score_factors"] = json.loads(result.pop("score_factors", "[]"))
return result
def existing_businesses(db: sqlite3.Connection) -> list[dict]:
return [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY id", (ORGANIZATION_ID,))]
def suppression_rows(db: sqlite3.Connection) -> list[dict]:
return [dict(r) for r in db.execute("SELECT kind, value FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,))]
class ApiHandler(BaseHTTPRequestHandler):
server_version = "ProspectPlatform/0.1"
def send_json(self, status: int, payload: dict | list):
body = json.dumps(payload, sort_keys=True).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def read_json(self) -> dict:
try:
length = int(self.headers.get("Content-Length", "0"))
value = json.loads(self.rfile.read(length) or b"{}")
return value if isinstance(value, dict) else {}
except (ValueError, json.JSONDecodeError):
return {}
def db(self):
return connect(self.server.db_path)
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path.rstrip("/")
if path == "/api/v1/health/live":
return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID})
db = self.db()
try:
if path == "/api/v1/dashboard/summary":
row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()
return self.send_json(200, {"organization_id": ORGANIZATION_ID, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()[0]})
if path == "/api/v1/businesses":
query = parse_qs(parsed.query).get("q", [""])[0].strip()
if query:
like = f"%{query}%"
rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?) ORDER BY score DESC, id", (ORGANIZATION_ID, like, like, like)).fetchall()
else:
rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY score DESC, id", (ORGANIZATION_ID,)).fetchall()
return self.send_json(200, {"organization_id": ORGANIZATION_ID, "items": [row_json(r) for r in rows]})
if path.startswith("/api/v1/businesses/"):
ident = path.rsplit("/", 1)[1]
if not ident.isdigit(): return self.send_json(404, {"error": "not_found"})
row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), ORGANIZATION_ID)).fetchone()
return self.send_json(200, row_json(row)) if row else self.send_json(404, {"error": "not_found"})
return self.send_json(404, {"error": "not_found"})
finally:
db.close()
def do_POST(self):
path = urlparse(self.path).path.rstrip("/")
payload = self.read_json()
if path == "/api/v1/businesses": return self.create_business(payload)
if path == "/api/v1/suppressions": return self.create_suppression(payload)
if path == "/api/v1/imports/preview": return self.preview_import(payload)
return self.send_json(404, {"error": "not_found"})
def create_business(self, payload):
if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"})
business = normalize_business(payload)
db = self.db()
try:
if is_suppressed(business, suppression_rows(db)): return self.send_json(409, {"error": "suppressed"})
identity_fields = [("website_domain", business["website_domain"]), ("email", business["email"]), ("phone", business["phone"])]
identity_fields = [(column, value) for column, value in identity_fields if value]
if identity_fields:
predicates = " OR ".join(f"{column} = ?" for column, _ in identity_fields)
values = [value for _, value in identity_fields]
if db.execute(f"SELECT id FROM businesses WHERE organization_id = ? AND ({predicates})", [ORGANIZATION_ID, *values]).fetchone():
return self.send_json(409, {"error": "duplicate"})
scored = score_business(business)
cur = db.execute("INSERT INTO businesses (organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (ORGANIZATION_ID, business["name"], business["website"], business["website_domain"], business["email"], business["phone"], str(business.get("description", "")), scored["score"], scored["score_version"], json.dumps(scored["factors"]), scored["website_class"]))
db.commit()
return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ?", (cur.lastrowid,)).fetchone()))
finally: db.close()
def create_suppression(self, payload):
kind, value = payload.get("kind"), str(payload.get("value", "")).strip().lower()
if kind not in {"email", "domain", "phone"} or not value: return self.send_json(400, {"error": "invalid_suppression"})
db = self.db()
try:
try:
db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (ORGANIZATION_ID, kind, value)); db.commit()
except sqlite3.IntegrityError: pass
row = db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (ORGANIZATION_ID, kind, value)).fetchone()
return self.send_json(201, dict(row))
finally: db.close()
def preview_import(self, payload):
rows = payload.get("rows", [])
if not isinstance(rows, list): return self.send_json(400, {"error": "rows_required"})
normalized = deduplicate_businesses([r for r in rows if isinstance(r, dict) and str(r.get("name", "")).strip()])
db = self.db()
try:
suppressions = suppression_rows(db); existing = existing_businesses(db); seen = set()
accepted, duplicate, suppressed = [], 0, 0
for b in normalized:
key = deduplication_key(b)
if is_suppressed(b, suppressions): suppressed += 1
elif (key in {deduplication_key(x) for x in existing} or key in seen): duplicate += 1
else: seen.add(key); accepted.append(b)
return self.send_json(200, {"accepted": len(accepted), "duplicates": duplicate + len(rows) - len(normalized), "suppressed": suppressed, "rows": accepted})
finally: db.close()
def log_message(self, *_): pass
def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"):
server = ThreadingHTTPServer((host, port), ApiHandler)
server.db_path = db_path
connect(db_path).close()
return server
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Prospect Platform API")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=int(os.environ.get("PROSPECT_API_PORT", "8000")))
parser.add_argument("--db", default=os.environ.get("PROSPECT_API_DB", "prospects.db"))
args = parser.parse_args()
server = create_server(args.host, args.port, args.db)
print(f"Prospect API listening on http://{args.host}:{args.port}", flush=True)
try: server.serve_forever()
except KeyboardInterrupt: pass
finally: server.server_close()
+28
View File
@@ -0,0 +1,28 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS businesses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL,
name TEXT NOT NULL,
website TEXT NOT NULL DEFAULT '',
website_domain TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
score INTEGER NOT NULL DEFAULT 0,
score_version TEXT NOT NULL DEFAULT 'mvp-1',
score_factors TEXT NOT NULL DEFAULT '[]',
website_class TEXT NOT NULL DEFAULT 'missing',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_businesses_org ON businesses(organization_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_domain ON businesses(organization_id, website_domain) WHERE website_domain <> '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_email ON businesses(organization_id, email) WHERE email <> '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_phone ON businesses(organization_id, phone) WHERE phone <> '';
CREATE TABLE IF NOT EXISTS suppressions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('email','domain','phone')),
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(organization_id, kind, value)
);
+34
View File
@@ -0,0 +1,34 @@
"""Small stdlib-only MVP API/container smoke-test service."""
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
server_version = "ProspectPlatformMVP/0.1"
def do_GET(self): # noqa: N802
if self.path == "/healthz":
self.respond(200, {"status": "ok", "outreach_enabled": False})
elif self.path == "/":
self.respond(200, {"service": "api", "status": "ok"})
else:
self.respond(404, {"error": "not_found"})
def respond(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
if os.environ.get("LOG_LEVEL", "INFO").upper() != "QUIET":
super().log_message(format, *args)
if __name__ == "__main__":
host = os.environ.get("API_HOST", "0.0.0.0")
port = int(os.environ.get("API_PORT", "8000"))
ThreadingHTTPServer((host, port), Handler).serve_forever()
View File
+55
View File
@@ -0,0 +1,55 @@
import json
import threading
import unittest
from http.client import HTTPConnection
from tempfile import TemporaryDirectory
from app.main import create_server
class ApiSmokeTests(unittest.TestCase):
def setUp(self):
self.tmp = TemporaryDirectory()
self.server = create_server("127.0.0.1", 0, self.tmp.name + "/test.db")
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
self.tmp.cleanup()
def request(self, method, path, payload=None):
body = json.dumps(payload).encode() if payload is not None else None
self.conn.request(method, path, body, {"Content-Type": "application/json"} if body else {})
response = self.conn.getresponse()
return response.status, json.loads(response.read())
def test_health_create_get_summary_and_import_preview(self):
self.assertEqual(self.request("GET", "/api/v1/health/live")[0], 200)
status, created = self.request("POST", "/api/v1/businesses", {"name": "Acme", "website": "https://acme.co.za", "email": "a@acme.co.za"})
self.assertEqual(status, 201)
self.assertEqual(created["organization_id"], "demo-tenant")
status, fetched = self.request("GET", "/api/v1/businesses/" + str(created["id"]))
self.assertEqual(status, 200)
self.assertEqual(fetched["name"], "Acme")
status, summary = self.request("GET", "/api/v1/dashboard/summary")
self.assertEqual(status, 200)
self.assertEqual(summary["businesses"], 1)
status, preview = self.request("POST", "/api/v1/imports/preview", {"rows": [{"name": "Acme", "website": "https://acme.co.za"}, {"name": "New Co"}]})
self.assertEqual(status, 200)
self.assertEqual(preview["accepted"], 1)
self.assertEqual(preview["duplicates"], 1)
def test_suppression_blocks_new_business(self):
status, _ = self.request("POST", "/api/v1/suppressions", {"kind": "domain", "value": "blocked.co.za"})
self.assertEqual(status, 201)
status, response = self.request("POST", "/api/v1/businesses", {"name": "Blocked", "website": "https://blocked.co.za"})
self.assertEqual(status, 409)
self.assertEqual(response["error"], "suppressed")
if __name__ == "__main__":
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
import unittest
from app.domain import (
classify_website,
deduplicate_businesses,
normalize_business,
normalize_domain,
score_business,
is_suppressed,
)
class DomainTests(unittest.TestCase):
def test_normalization_canonicalizes_contact_and_domain(self):
value = normalize_business({"name": " Acme ", "website": "HTTPS://WWW.Acme.co.za/path", "email": " SALES@ACME.CO.ZA "})
self.assertEqual(value["name"], "Acme")
self.assertEqual(value["website_domain"], "acme.co.za")
self.assertEqual(value["email"], "sales@acme.co.za")
def test_website_classification_is_conservative(self):
self.assertEqual(classify_website("https://acme.co.za"), "business_site")
self.assertEqual(classify_website("https://www.facebook.com/acme"), "social_profile")
self.assertEqual(classify_website(""), "missing")
def test_scoring_is_versioned_and_explains_factors(self):
result = score_business({"name": "Acme", "website_domain": "acme.co.za", "email": "a@acme.co.za", "phone": "123"})
self.assertEqual(result["score_version"], "mvp-1")
self.assertGreaterEqual(result["score"], 70)
self.assertIn("business_site", result["factors"])
def test_suppression_matches_email_domain_or_phone(self):
business = {"email": "person@example.com", "website_domain": "example.com", "phone": "+27123456789"}
self.assertTrue(is_suppressed(business, [{"kind": "domain", "value": "example.com"}]))
self.assertTrue(is_suppressed(business, [{"kind": "email", "value": "person@example.com"}]))
self.assertTrue(is_suppressed(business, [{"kind": "phone", "value": "+27123456789"}]))
self.assertFalse(is_suppressed(business, [{"kind": "email", "value": "other@example.com"}]))
def test_deduplication_prefers_richer_record(self):
rows = [{"name": "Acme", "website": "https://acme.com"}, {"name": " acme ", "website": "https://www.acme.com", "email": "a@acme.com"}]
result = deduplicate_businesses(rows)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["email"], "a@acme.com")
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.13-alpine
RUN addgroup -S app && adduser -S -G app app
WORKDIR /srv
COPY index.html /srv/index.html
COPY styles.css /srv/styles.css
COPY app.js /srv/app.js
COPY healthz /srv/healthz
RUN chown -R app:app /srv
USER app
EXPOSE 8080
CMD ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv"]
+32
View File
@@ -0,0 +1,32 @@
# ProspectOS web MVP
Self-contained static frontend for the Prospect Platform API. There is no bundler or runtime dependency: serve this directory with any static HTTP server.
## Configure and run
The API base is configurable before `app.js` runs:
```html
<script>window.API_BASE = 'http://127.0.0.1:8000';</script>
<script src="app.js"></script>
```
If not set, the UI uses `localStorage.prospect_api_base` when present, then renders clearly-labelled demo data. The API contract used by this page is the current MVP contract:
- `GET /api/v1/businesses` (optional filtering is performed client-side)
- `GET /api/v1/dashboard/summary`
- `POST /api/v1/businesses`
The CSV control is intentionally preview-only. The backend's `POST /api/v1/imports/preview` can be wired to a confirmation flow later; this UI does not claim that import rows have been persisted.
## Browser verification
1. Start the API from `apps/api` with `python3 app/main.py`.
2. Serve this directory: `python3 -m http.server 8080 --directory apps/web`.
3. Open `http://127.0.0.1:8080`, with `window.API_BASE` set to `http://127.0.0.1:8000` using a tiny pre-load edit or browser devtools.
4. Confirm the header changes to **API connected**, metrics populate, search and score/status filters update the table, selecting a row opens evidence/confidence/freshness, and adding a prospect POSTs to `/api/v1/businesses`.
5. Confirm rows with `status: review` show **Outreach unavailable — Review this prospect before outreach is available**, and suppressed rows show **Outreach unavailable — Suppressed records cannot be contacted**. There is no outreach/send endpoint or button.
6. Select a CSV and confirm a local, preview-only table appears without a network request.
7. Resize below 700px to verify the collapsible nav, stacked panels, and horizontally scrollable explorer table.
A zero-dependency static smoke page (`smoke-test.html`) checks the key DOM contract in an iframe and reports pass/fail.
+60
View File
@@ -0,0 +1,60 @@
/* ProspectOS frontend MVP. Configure before loading with window.API_BASE = 'http://127.0.0.1:8000'; */
(() => {
'use strict';
const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, '');
const endpoint = (path) => `${API_BASE}${path}`;
const demoProspects = [
{id:1,name:'Northstar Creative',website:'https://northstarcreative.co.za',website_domain:'northstarcreative.co.za',location:'Cape Town, ZA',score:92,score_factors:['named_business','business_site','email','phone'],email:'hello@northstarcreative.co.za',phone:'+27215550101',updated_at:'2026-08-31T09:00:00Z',status:'reviewed',confidence:'High'},
{id:2,name:'Berg & Bloom',website:'https://bergandbloom.co.za',website_domain:'bergandbloom.co.za',location:'Johannesburg, ZA',score:78,score_factors:['named_business','business_site','description'],description:'Independent retail studio',updated_at:'2026-08-29T09:00:00Z',status:'review',confidence:'Medium'},
{id:3,name:'Mosaic Studio',website:'',website_domain:'',location:'Durban, ZA',score:45,score_factors:['named_business'],updated_at:'2026-08-12T09:00:00Z',status:'review',confidence:'Low'},
{id:4,name:'Cedar Works',website:'https://cedarworks.co.za',website_domain:'cedarworks.co.za',location:'Pretoria, ZA',score:83,score_factors:['named_business','business_site','phone'],phone:'+27125550102',updated_at:'2026-08-30T09:00:00Z',status:'reviewed',confidence:'High'},
{id:5,name:'Studio Lumen',website:'https://instagram.com/studiolumen',website_domain:'instagram.com',location:'Gqeberha, ZA',score:55,score_factors:['named_business'],updated_at:'2026-08-20T09:00:00Z',status:'suppressed',suppressed:true,suppression_reason:'Suppressed by domain match',confidence:'Low'},
{id:6,name:'Field Notes Co.',website:'https://fieldnotes.example',website_domain:'fieldnotes.example',location:'Cape Town, ZA',score:67,score_factors:['named_business','business_site'],updated_at:'2026-08-25T09:00:00Z',status:'review',confidence:'Medium'}
];
let prospects = [];
let selectedId = null;
const $ = (id) => document.getElementById(id);
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const scoreClass = (score) => score >= 80 ? 'high' : score >= 60 ? 'medium' : 'low';
const statusOf = (p) => p.suppressed || p.status === 'suppressed' ? 'suppressed' : (p.reviewed || p.status === 'reviewed' || p.reviewed_at ? 'reviewed' : 'review');
const freshness = (p) => {
const raw = p.updated_at || p.last_checked_at || p.created_at;
if (!raw) return {label:'Unknown', cls:'stale'};
const days = Math.max(0, Math.floor((Date.now() - new Date(raw).getTime()) / 86400000));
return {label: days === 0 ? 'Today' : `${days}d ago`, cls: days <= 7 ? 'good' : 'stale'};
};
const scoreFor = (p) => Number.isFinite(Number(p.score)) ? Number(p.score) : (p.score_factors || p.factors || []).reduce((n, f) => n + ({named_business:20,business_site:30,email:25,phone:15,description:10}[f] || 0), 0);
const labelFactor = (f) => ({named_business:'Named business',business_site:'Business site',email:'Email found',phone:'Phone found',description:'Description'}[f] || String(f).replaceAll('_',' '));
function renderMetrics(summary) {
const total = Number(summary?.businesses ?? summary?.total ?? prospects.length);
const high = prospects.filter(p => scoreFor(p) >= 80).length;
const review = prospects.filter(p => statusOf(p) === 'review').length;
const fresh = prospects.length ? Math.round(prospects.filter(p => freshness(p).cls === 'good').length / prospects.length * 100) : 0;
$('metricTotal').textContent = total; $('heroCount').textContent = `${total} prospects`;
$('metricReview').textContent = summary?.needs_review ?? review; $('metricHigh').textContent = summary?.high_fit ?? high; $('metricFresh').textContent = `${summary?.freshness_under_7d ?? fresh}%`;
}
function filtered() {
const q = $('searchInput').value.trim().toLowerCase(), sf = $('scoreFilter').value, st = $('statusFilter').value;
return prospects.filter(p => { const s=scoreFor(p), text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(); return (!q || text.includes(q)) && (sf==='all' || (sf==='high'&&s>=80) || (sf==='medium'&&s>=60&&s<80) || (sf==='low'&&s<60)) && (st==='all' || statusOf(p)===st); });
}
function renderRows() {
const rows = filtered(); $('resultCount').textContent = `Showing ${rows.length} prospect${rows.length===1?'':'s'}`;
$('prospectRows').innerHTML = rows.length ? rows.map(p => { const s=scoreFor(p), f=freshness(p), st=statusOf(p), factors=p.score_factors||p.factors||[]; return `<tr data-id="${esc(p.id)}" class="${p.id===selectedId?'selected':''}"><td>${esc(p.name)}<span class="company-sub">${esc(p.website_domain || 'no detected website')}</span></td><td><span class="score ${scoreClass(s)}">${s} <small>/ 100</small></span></td><td class="evidence"><strong>${factors.length} signal${factors.length===1?'':'s'}</strong>${esc(factors.slice(0,2).map(labelFactor).join(' · ') || 'Limited evidence')}</td><td><span class="fresh ${f.cls}">${f.label}</span></td><td><span class="status ${st}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</span></td><td class="row-arrow"></td></tr>`; }).join('') : '<tr><td colspan="6" class="muted">No prospects match these filters.</td></tr>';
document.querySelectorAll('#prospectRows tr[data-id]').forEach(row => row.addEventListener('click', () => { selectedId = Number(row.dataset.id); renderRows(); renderDetail(); }));
}
function renderDetail() {
const p = prospects.find(x => Number(x.id) === Number(selectedId)); if (!p) return;
const s=scoreFor(p), st=statusOf(p), f=freshness(p), factors=p.score_factors||p.factors||[], blocked=st==='review' || st==='suppressed';
$('detailPanel').innerHTML = `<div class="detail-head"><div><p class="eyebrow">PROSPECT DETAIL</p><h3>${esc(p.name)}</h3><p class="detail-domain">${esc(p.website_domain || 'no detected website')}</p></div><span class="status ${st}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</span></div><div class="detail-score"><div><small>Fit score</small><b>${s}<small>/ 100</small></b></div><span class="score ${scoreClass(s)}">${esc(p.confidence || (s>=80?'High':s>=60?'Medium':'Low'))} confidence</span></div><div class="detail-block"><h4>Evidence & signals</h4>${factors.length ? factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence || 'Medium')}</span></p>`).join('') : '<p>Limited evidence available for this record.</p>'}</div><div class="detail-block"><h4>Data quality</h4><p class="evidence-line"><span>Last checked</span><span>${f.label}</span></p><p class="evidence-line"><span>Website</span><span>${p.website_domain?'Detected':'no detected website'}</span></p></div>${blocked?`<button class="button disabled-action" disabled aria-disabled="true">Outreach unavailable</button><p class="disabled-reason">${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}</p>`:''}`;
}
async function loadData() {
$('apiStatus').textContent = API_BASE ? '● Connecting…' : '● Demo data';
try { const [listRes, summaryRes] = await Promise.all([fetch(endpoint('/api/v1/businesses')), fetch(endpoint('/api/v1/dashboard/summary'))]); if (!listRes.ok || !summaryRes.ok) throw new Error('API request failed'); const list=await listRes.json(), summary=await summaryRes.json(); prospects=Array.isArray(list)?list:(list.businesses||list.items||[]); renderMetrics(summary); $('apiStatus').textContent='● API connected'; $('apiStatus').classList.add('live'); } catch (error) { prospects=demoProspects; renderMetrics(null); $('apiStatus').textContent=API_BASE?'● API unavailable · demo data':'● Demo data'; }
renderRows();
}
async function addProspect(event) { event.preventDefault(); const data=Object.fromEntries(new FormData(event.currentTarget).entries()); const msg=$('formMessage'); if (!API_BASE) { prospects.unshift({...data,id:`local-${Date.now()}`,score:data.website?50:20,status:'review',confidence:'Low'}); msg.textContent='Added to local preview review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); return; } try { const res=await fetch(endpoint('/api/v1/businesses'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); const body=await res.json(); if(!res.ok) throw new Error(body.error||'Could not add prospect'); prospects.unshift(body); msg.textContent='Added to review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); } catch(e) { msg.textContent=e.message; msg.className='form-message error'; } }
function parseCsv(text) { const lines=text.trim().split(/\r?\n/).filter(Boolean), cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[]; if(!lines.length)return []; const headers=cells(lines[0]); return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v]))); }
function renderCsv(rows) { if(!rows.length){$('csvPreview').innerHTML='<span>⊞</span><p>No data rows found</p>';return;} const h=Object.keys(rows[0]); $('csvPreview').className='csv-table'; $('csvPreview').innerHTML=`<table><thead><tr>${h.map(x=>`<th>${esc(x)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${h.map(x=>`<td>${esc(r[x])}</td>`).join('')}</tr>`).join('')}</tbody></table><small class="muted">Showing up to 10 rows · Preview only; nothing added yet.</small>`; }
$('searchInput').addEventListener('input',renderRows); $('scoreFilter').addEventListener('change',renderRows); $('statusFilter').addEventListener('change',renderRows); $('refreshBtn').addEventListener('click',loadData); $('addForm').addEventListener('submit',addProspect); $('csvInput').addEventListener('change',e=>{const file=e.target.files[0]; if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}}); $('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open')); document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView()));
loadData();
})();
+1
View File
@@ -0,0 +1 @@
ok
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ProspectOS · Pipeline intelligence</title>
<meta name="description" content="Prospect discovery and review dashboard">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href="#top" aria-label="ProspectOS home"><span class="brand-mark"></span><span>Prospect<span class="brand-light">OS</span></span></a>
<nav aria-label="Primary navigation">
<a class="nav-item active" href="#dashboard"><span></span> Dashboard</a>
<a class="nav-item" href="#explorer"><span></span> Prospect explorer</a>
<a class="nav-item" href="#add"><span></span> Add prospects</a>
</nav>
<div class="sidebar-foot"><div class="live-dot"></div><div><strong>Workspace live</strong><small>Data sync is healthy</small></div></div>
</aside>
<main class="main" id="top">
<header class="topbar"><button class="mobile-menu" id="menuBtn" aria-label="Toggle navigation"></button><div class="crumb">Workspace <span>/</span> Growth pipeline</div><div class="top-actions"><span class="api-status" id="apiStatus">● Demo data</span><button class="icon-button" aria-label="Notifications"></button><div class="avatar">AR</div></div></header>
<div class="content">
<section class="hero" id="dashboard"><div><p class="eyebrow">EVIDENCE-LED PROSPECTING</p><h1>Good morning, Alex <span></span></h1><p class="hero-sub">Your pipeline has <strong id="heroCount">0 prospects</strong> ready for review.</p></div><button class="button primary" data-scroll="#add"> Add prospect</button></section>
<section class="metrics" aria-label="Dashboard metrics">
<article class="metric-card"><div class="metric-icon violet"></div><div><p>Total prospects</p><h2 id="metricTotal">0</h2><span class="trend neutral">● Current workspace</span></div></article>
<article class="metric-card"><div class="metric-icon amber"></div><div><p>Needs review</p><h2 id="metricReview">0</h2><span class="trend neutral">● Human verification</span></div></article>
<article class="metric-card"><div class="metric-icon green"></div><div><p>High-fit prospects</p><h2 id="metricHigh">0</h2><span class="trend neutral">● Score 80+</span></div></article>
<article class="metric-card"><div class="metric-icon blue"></div><div><p>Freshness under 7d</p><h2 id="metricFresh">0%</h2><span class="trend neutral">● Evidence coverage</span></div></article>
</section>
<section class="workspace-grid" id="explorer">
<div class="explorer-panel panel"><div class="panel-heading"><div><p class="eyebrow">PIPELINE</p><h2>Prospect explorer</h2></div><button class="button ghost" id="refreshBtn">↻ Refresh</button></div>
<div class="filters"><label class="search-wrap"><span></span><input id="searchInput" type="search" placeholder="Search companies, domains, locations…" autocomplete="off"></label><select id="scoreFilter" aria-label="Filter by score"><option value="all">All scores</option><option value="high">High fit · 80+</option><option value="medium">Medium · 6079</option><option value="low">Low · under 60</option></select><select id="statusFilter" aria-label="Filter by status"><option value="all">All statuses</option><option value="review">Needs review</option><option value="reviewed">Reviewed</option><option value="suppressed">Suppressed</option></select></div>
<div class="table-meta"><span id="resultCount">Showing 0 prospects</span><span class="legend"><span class="legend-dot high-dot"></span> High fit <span class="legend-dot review-dot"></span> Needs review</span></div>
<div class="table-scroll"><table><thead><tr><th>Company</th><th>Fit score</th><th>Evidence</th><th>Freshness</th><th>Status</th><th></th></tr></thead><tbody id="prospectRows"></tbody></table></div>
</div>
<aside class="detail-panel panel" id="detailPanel"><div class="empty-detail"><span class="empty-icon"></span><h3>Select a prospect</h3><p>Review evidence, confidence, and eligibility before taking action.</p></div></aside>
</section>
<section class="lower-grid" id="add"><article class="panel add-panel"><div class="panel-heading"><div><p class="eyebrow">INTAKE</p><h2>Add a prospect</h2></div><span class="small-label">Manual entry</span></div><form id="addForm"><div class="form-grid"><label>Company name<input required name="name" placeholder="Acme Inc."></label><label>Website <span class="optional">optional</span><input name="website" type="url" placeholder="https://acme.com"></label><label>Location<input name="location" placeholder="Cape Town, ZA"></label><label>Notes <span class="optional">optional</span><input name="description" placeholder="Why this is a fit…"></label></div><div class="form-footer"><p id="formMessage" class="form-message" role="status"></p><button class="button primary" type="submit">Add to review queue</button></div></form></article>
<article class="panel csv-panel"><div class="panel-heading"><div><p class="eyebrow">BULK INTAKE</p><h2>CSV preview</h2></div><label class="button ghost upload-label" for="csvInput">↑ Choose CSV</label><input id="csvInput" type="file" accept=".csv,text/csv" hidden></div><p class="muted">Preview rows before adding them to your review queue.</p><div id="csvPreview" class="csv-empty"><span></span><p>No file selected</p><small>CSV stays in your browser until you confirm.</small></div></article></section>
<footer><span>ProspectOS</span><span>Evidence-led prospecting · <a href="#explorer">Review queue</a></span></footer>
</div>
</main>
</div>
<script src="app.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>ProspectOS smoke test</title><style>body{font:16px system-ui;padding:2rem;background:#f7f8fb;color:#172033}li{margin:.5rem 0}.pass{color:#16845b}.fail{color:#b84d55}</style><h1>ProspectOS static smoke test</h1><p id="summary">Running…</p><ul id="checks"></ul><iframe id="app" src="index.html" hidden></iframe><script>const checks=[['Dashboard metrics',d=>!!d.querySelector('#metricTotal')],['Explorer table',d=>!!d.querySelector('#prospectRows')],['Add prospect form',d=>!!d.querySelector('#addForm')],['CSV preview control',d=>!!d.querySelector('#csvInput')],['No outreach/send controls',d=>!Array.from(d.querySelectorAll('button')).some(x=>/outreach|send/i.test(x.textContent)&&!x.disabled)]];const frame=document.querySelector('#app');frame.onload=()=>setTimeout(()=>{const d=frame.contentDocument;let passed=0;document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test(d);if(ok)passed++;return `<li class="${ok?'pass':'fail'}">${ok?'PASS':'FAIL'}${name}</li>`}).join('');document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;},500);</script>
File diff suppressed because one or more lines are too long
+60
View File
@@ -0,0 +1,60 @@
services:
api:
build:
context: ./apps/api
dockerfile: Dockerfile
image: prospect-platform-api:local
environment:
APP_ENV: ${APP_ENV:-development}
API_HOST: 0.0.0.0
API_PORT: 8000
LOG_LEVEL: ${LOG_LEVEL:-INFO}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080}
DATA_DIR: /data
AUTOMATED_OUTREACH_ENABLED: "false"
ports:
- "${API_PORT:-8000}:8000"
volumes:
- prospect_api_data:/data
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/live', timeout=2)"]
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
restart: unless-stopped
web:
build:
context: ./apps/web
dockerfile: Dockerfile
image: prospect-platform-web:local
environment:
AUTOMATED_OUTREACH_ENABLED: "false"
ports:
- "${WEB_PORT:-8080}:8080"
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 3s
restart: unless-stopped
volumes:
prospect_api_data:
name: prospect-platform-api-data
+67
View File
@@ -0,0 +1,67 @@
# Operations Runbook
## Start, inspect, stop
From the repository root:
```sh
docker compose up --build -d
docker compose ps
docker compose logs --follow api web
docker compose down
```
The expected health endpoints are:
- API: `GET http://localhost:8000/healthz`
- Web: `GET http://localhost:8080/healthz`
A service is ready only when Compose reports `healthy`; container running status alone is insufficient. The API health response includes `"outreach_enabled": false` as an operational safety check.
## Configuration and deployment
Copy `.env.example` for local development. Production values must be supplied by the deployment environment, never committed. Keep `AUTOMATED_OUTREACH_ENABLED=false`; automated outreach is explicitly disabled in this MVP and there is no supported production enablement path in this repository.
Before deployment:
1. Run `docker compose config` and review the rendered configuration.
2. Build from a reviewed commit and scan the resulting images.
3. Restrict host/network exposure at the ingress/firewall.
4. Verify both health checks and review logs for unexpected errors or sensitive data.
5. Record the image digest and configuration revision for rollback.
## Data, backups, and retention
The canonical API runtime under `apps/api` uses the named Docker volume `prospect-platform-api-data`. Inspect it with `docker volume inspect prospect-platform-api-data`; do not treat a local Docker volume as a backup.
For the current MVP there is no database migration or backup command. If runtime data is material, stop writes first and snapshot/copy the volume using an approved host backup process. Protect backup files with encryption and access controls, test a restore into an isolated environment, and document the result.
Recommended starting policy for a future production data store:
- daily encrypted backups, with at least 30 days of retention;
- point-in-time recovery where supported;
- one offline or separately isolated copy;
- quarterly restore drills, plus a restore test after storage/provider changes;
- retention and deletion schedules aligned with the source/contact policy and applicable law.
Do not run `docker compose down -v` on a data-bearing environment: it removes the named volume.
## Failure handling
- **Unhealthy API:** inspect `docker compose logs api`, verify port binding and resource availability, then restart with `docker compose restart api` if appropriate.
- **Unhealthy web:** inspect `docker compose logs web`; confirm port `8080` is available and the image contains `/healthz`.
- **Build failure:** run `docker compose build --no-cache` from a reviewed checkout and check Docker daemon/network status.
- **Unexpected outbound traffic:** stop the stack, preserve logs/metadata, and investigate. The MVP has no outreach worker and must not send automated messages.
## Scaling path
Adding Postgres, Redis, workers, or schedulers requires explicit readiness checks, migrations, queue durability/idempotency, secret injection, network segmentation, metrics/alerts, backup/restore procedures, and an operational owner. Do not add them as an implicit Compose dependency: this MVP is intentionally runnable without external Postgres or Redis.
## Incident checklist
1. Record time, affected service, image/config revision, and observed health state.
2. Preserve relevant logs without exporting secrets or unnecessary contact data.
3. Stop or isolate the affected service if data loss, unauthorized access, SSRF, or unexpected outreach is suspected.
4. Rotate exposed credentials through the secret manager.
5. Validate recovery with health checks and a targeted smoke test.
6. Document root cause, corrective action, and any retention/suppression impact.
+28
View File
@@ -0,0 +1,28 @@
# Security Notes
## Current safety boundary
- **Automated outreach is disabled.** The compose file sets `AUTOMATED_OUTREACH_ENABLED=false` for both services. The MVP sends no email, SMS, or other outbound communication.
- No credentials are committed. `.env.example` contains non-secret names and local defaults only.
- Containers run as an unprivileged user, drop Linux capabilities, use `no-new-privileges`, and use read-only root filesystems. The API data volume is the only intended writable persistent location.
- The stdlib API is a health/smoke service, not a production security boundary. It has no user authentication, authorization, CSRF protection, rate limiting, or audit log implementation.
## Known limitations before production
1. **Authentication and authorization:** add an identity provider/session or signed-token design, enforce authorization server-side on every protected route, rotate sessions/tokens, and test tenant isolation.
2. **CSRF:** browser state-changing endpoints must use same-site cookies plus CSRF tokens (or a rigorously reviewed equivalent). Do not rely on CORS as CSRF protection.
3. **SSRF:** any future URL fetcher must allow only `http`/`https`, validate DNS/IP targets, block loopback/private/link-local/cloud-metadata ranges after resolution, limit redirects, enforce size/time limits, and re-check each redirect. Never fetch arbitrary user-provided URLs from the server without these controls.
4. **Input/output safety:** validate schema and content types, bound request sizes, parameterize database queries, escape output, and avoid logging contact data or secrets.
5. **Secrets:** inject production secrets from a secret manager or orchestrator secret store. Do not place them in images, Compose files, source, CI logs, or committed `.env` files.
6. **Transport and perimeter:** terminate TLS at a trusted ingress, restrict exposed ports, add network policy, and place admin surfaces behind appropriate access controls.
7. **Data protection:** define retention and deletion rules for prospect/contact data, restrict volume access, encrypt backups, and maintain an access/audit trail.
## Source and contact policy
Treat discovered business information as potentially personal or copyrighted data. Collect only what is needed for the documented product purpose, preserve source attribution where required, respect site terms and robots/access policies, and provide suppression/deletion handling. Do not infer consent to contact from public availability. Any future outreach feature requires an explicit product/legal review and must remain off by default.
## CI/dependency hygiene
Pin or review base-image and dependency updates, scan images before release, use least-privilege GitHub tokens, and avoid printing environment values. CI in this MVP performs build and health checks; it is not a substitute for a security assessment.
Report vulnerabilities privately to the repository maintainers; do not include live credentials or personal data in an issue.
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.13-alpine
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN addgroup -S app && adduser -S -G app app
WORKDIR /app
COPY infrastructure/docker/api/server.py /app/server.py
RUN chown -R app:app /app
USER app
EXPOSE 8000
CMD ["python", "/app/server.py"]
+34
View File
@@ -0,0 +1,34 @@
"""Small stdlib-only MVP API/container smoke-test service."""
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
server_version = "ProspectPlatformMVP/0.1"
def do_GET(self): # noqa: N802
if self.path == "/healthz":
self.respond(200, {"status": "ok", "outreach_enabled": False})
elif self.path == "/":
self.respond(200, {"service": "api", "status": "ok"})
else:
self.respond(404, {"error": "not_found"})
def respond(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
if os.environ.get("LOG_LEVEL", "INFO").upper() != "QUIET":
super().log_message(format, *args)
if __name__ == "__main__":
host = os.environ.get("API_HOST", "0.0.0.0")
port = int(os.environ.get("API_PORT", "8000"))
ThreadingHTTPServer((host, port), Handler).serve_forever()
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.13-alpine
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN addgroup -S app && adduser -S -G app app
WORKDIR /srv
COPY infrastructure/docker/web/index.html /srv/index.html
COPY infrastructure/docker/web/healthz /srv/healthz
RUN chown -R app:app /srv
USER app
EXPOSE 8080
CMD ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv"]
+1
View File
@@ -0,0 +1 @@
ok
+6
View File
@@ -0,0 +1,6 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prospect Platform</title>
<h1>Prospect Platform</h1>
<p>MVP static web container is running.</p>
<p>Automated outreach is <strong>disabled</strong>.</p>