add authenticated tenant-scoped sessions

This commit is contained in:
Marco0300
2026-09-02 17:45:57 +02:00
parent 8fa391a000
commit a52af65024
12 changed files with 309 additions and 115 deletions
+4
View File
@@ -4,5 +4,9 @@ LOG_LEVEL=INFO
API_PORT=8000 API_PORT=8000
WEB_PORT=8080 WEB_PORT=8080
CORS_ORIGINS=http://localhost:8080 CORS_ORIGINS=http://localhost:8080
# Optional first-run admin bootstrap. Set both only for a fresh deployment;
# remove them after the admin account has been created. Never commit credentials.
BOOTSTRAP_ADMIN_EMAIL=
BOOTSTRAP_ADMIN_PASSWORD=
# Deliberately fixed to false in compose for this MVP. # Deliberately fixed to false in compose for this MVP.
AUTOMATED_OUTREACH_ENABLED=false AUTOMATED_OUTREACH_ENABLED=false
+6 -1
View File
@@ -9,6 +9,7 @@ A safety-first MVP vertical slice for evidence-led prospect discovery and qualif
- JSON API under `/api/v1` for health, dashboard summary, businesses, suppression, and CSV-style import preview. - 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. - 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. - Docker Compose runtime with non-root containers, read-only filesystems, health checks, and a named SQLite data volume.
- Browser authentication with server-side sessions and an optional first-run admin bootstrap.
- Security and operations guidance in `docs/`. - Security and operations guidance in `docs/`.
## Run locally ## Run locally
@@ -49,7 +50,11 @@ curl -fsS http://localhost:8080/healthz
docker compose down 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. Compose passes the optional `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWORD` values to the API. Set both in an untracked `.env` only when provisioning a fresh instance, then remove them and rotate the password after the bootstrap admin is created. No credentials belong in this repository.
Authenticated browser requests use a server-side session cookie; login creates a session and logout invalidates it. The liveness endpoints (`GET /api/v1/health/live` and `GET /healthz`) intentionally remain unauthenticated so Docker, ingress, and monitoring health checks can use them. Authentication is not a substitute for tenant/authorization checks: protected routes must enforce the session and organization boundary server-side.
The initial pilot still omits Postgres, Redis, Celery, external discovery adapters, DNS/HTTP scanning, and outbound messaging. Before production use, complete the production security gates described in `docs/SECURITY.md`, including Argon2id password hashing, MFA for administrator accounts, TLS, CSRF protection, rate limiting, audit logging, migrations, SSRF-safe scanners, approved source registry, queue idempotency, and tested backups/restores.
## Verification ## Verification
+120 -79
View File
@@ -1,10 +1,14 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import json import json
import os import os
import secrets
import sqlite3 import sqlite3
import sys import sys
from datetime import datetime, timedelta, timezone
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@@ -17,6 +21,23 @@ else:
ORGANIZATION_ID = "demo-tenant" ORGANIZATION_ID = "demo-tenant"
SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql" SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
SESSION_DAYS = 7
PBKDF2_ITERATIONS = 300_000 # Development fallback: stdlib PBKDF2, not Argon2id.
MUTATING_ROLES = {"owner", "admin", "researcher"}
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
return digest.hex(), salt.hex()
def verify_password(password: str, encoded_hash: str, encoded_salt: str) -> bool:
try:
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(encoded_salt), PBKDF2_ITERATIONS).hex()
return secrets.compare_digest(digest, encoded_hash)
except (TypeError, ValueError):
return False
def connect(db_path: str) -> sqlite3.Connection: def connect(db_path: str) -> sqlite3.Connection:
@@ -24,6 +45,14 @@ def connect(db_path: str) -> sqlite3.Connection:
db.row_factory = sqlite3.Row db.row_factory = sqlite3.Row
db.execute("PRAGMA foreign_keys = ON") db.execute("PRAGMA foreign_keys = ON")
db.executescript(SCHEMA.read_text()) db.executescript(SCHEMA.read_text())
db.execute("INSERT OR IGNORE INTO organizations (id, name) VALUES (?, ?)", (ORGANIZATION_ID, "Demo organization"))
email, password = os.environ.get("BOOTSTRAP_ADMIN_EMAIL"), os.environ.get("BOOTSTRAP_ADMIN_PASSWORD")
if email and password:
existing = db.execute("SELECT id FROM users WHERE email = ?", (email.strip().lower(),)).fetchone()
if not existing:
password_hash, salt = hash_password(password)
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", (ORGANIZATION_ID, email.strip().lower(), password_hash, salt, "owner"))
db.commit()
return db return db
@@ -33,24 +62,18 @@ def row_json(row: sqlite3.Row) -> dict:
return result 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): class ApiHandler(BaseHTTPRequestHandler):
server_version = "ProspectPlatform/0.1" server_version = "ProspectPlatform/0.1"
def send_json(self, status: int, payload: dict | list): def send_json(self, status: int, payload: dict | list, extra_headers: dict[str, str] | None = None):
body = json.dumps(payload, sort_keys=True).encode("utf-8") body = json.dumps(payload, sort_keys=True).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") 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-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
self.send_header("Access-Control-Allow-Credentials", "true")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type") self.send_header("Access-Control-Allow-Headers", "Content-Type")
for key, value in (extra_headers or {}).items(): self.send_header(key, value)
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
@@ -63,116 +86,134 @@ class ApiHandler(BaseHTTPRequestHandler):
except (ValueError, json.JSONDecodeError): except (ValueError, json.JSONDecodeError):
return {} return {}
def db(self): def db(self): return connect(getattr(self.server, "db_path"))
return connect(self.server.db_path)
def do_OPTIONS(self): def do_OPTIONS(self):
self.send_response(204) 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-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
self.send_header("Access-Control-Allow-Credentials", "true")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type") self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers() self.end_headers()
def session_user(self, db: sqlite3.Connection):
cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", ""))
token = cookie.get("session")
if not token: return None
token_hash = hashlib.sha256(token.value.encode()).hexdigest()
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
return db.execute("SELECT u.id, u.email, u.role, u.organization_id FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ?", (token_hash, now)).fetchone()
def require_auth(self, db):
user = self.session_user(db)
if not user:
self.send_json(401, {"error": "unauthorized"})
return None
return user
def auth_cookie(self, token: str, max_age: int) -> str:
return f"session={token}; Max-Age={max_age}; Path=/; HttpOnly; SameSite=Lax"
def do_GET(self): def do_GET(self):
parsed = urlparse(self.path) parsed = urlparse(self.path); path = parsed.path.rstrip("/")
path = parsed.path.rstrip("/") if path == "/api/v1/health/live": return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID})
if path == "/api/v1/health/live":
return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID})
db = self.db() db = self.db()
try: try:
user = self.require_auth(db)
if not user: return
org = user["organization_id"]
if path == "/api/v1/auth/me": return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": org})
if path == "/api/v1/admin/users":
if user["role"] not in {"owner", "admin"}: return self.send_json(403, {"error": "forbidden"})
rows = db.execute("SELECT id,email,role,organization_id,created_at FROM users WHERE organization_id = ? ORDER BY id", (org,)).fetchall()
return self.send_json(200, {"items": [dict(r) for r in rows]})
if path == "/api/v1/dashboard/summary": 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() row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (org,)).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]}) return self.send_json(200, {"organization_id": org, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (org,)).fetchone()[0]})
if path == "/api/v1/businesses": if path == "/api/v1/businesses":
query = parse_qs(parsed.query).get("q", [""])[0].strip() query = parse_qs(parsed.query).get("q", [""])[0].strip(); params = [org]
sql = "SELECT * FROM businesses WHERE organization_id = ?"
if query: if query:
like = f"%{query}%" like = f"%{query}%"; sql += " AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?)"; params += [like] * 3
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() rows = db.execute(sql + " ORDER BY score DESC, id", params).fetchall()
else: return self.send_json(200, {"organization_id": org, "items": [row_json(r) for r in rows]})
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/"): if path.startswith("/api/v1/businesses/"):
ident = path.rsplit("/", 1)[1] ident = path.rsplit("/", 1)[1]
if not ident.isdigit(): return self.send_json(404, {"error": "not_found"}) 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() row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), org)).fetchone()
return self.send_json(200, row_json(row)) if row else self.send_json(404, {"error": "not_found"}) 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"}) return self.send_json(404, {"error": "not_found"})
finally: finally: db.close()
db.close()
def do_POST(self): def do_POST(self):
path = urlparse(self.path).path.rstrip("/") path = urlparse(self.path).path.rstrip("/")
payload = self.read_json() if path == "/api/v1/auth/login": return self.login(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() db = self.db()
try: try:
if is_suppressed(business, suppression_rows(db)): return self.send_json(409, {"error": "suppressed"}) user = self.require_auth(db)
identity_fields = [("website_domain", business["website_domain"]), ("email", business["email"]), ("phone", business["phone"])] if not user: return
identity_fields = [(column, value) for column, value in identity_fields if value] if path == "/api/v1/auth/logout":
if identity_fields: cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", "")); token = cookie.get("session")
predicates = " OR ".join(f"{column} = ?" for column, _ in identity_fields) if token: db.execute("DELETE FROM sessions WHERE token_hash = ?", (hashlib.sha256(token.value.encode()).hexdigest(),))
values = [value for _, value in identity_fields] db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "logout")); db.commit()
if db.execute(f"SELECT id FROM businesses WHERE organization_id = ? AND ({predicates})", [ORGANIZATION_ID, *values]).fetchone(): return self.send_json(200, {"ok": True}, {"Set-Cookie": self.auth_cookie("", 0)})
return self.send_json(409, {"error": "duplicate"}) if user["role"] not in MUTATING_ROLES: return self.send_json(403, {"error": "forbidden"})
scored = score_business(business) payload = self.read_json()
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"])) if path == "/api/v1/businesses": return self.create_business(payload, db, user["organization_id"])
db.commit() if path == "/api/v1/suppressions": return self.create_suppression(payload, db, user["organization_id"])
return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ?", (cur.lastrowid,)).fetchone())) if path == "/api/v1/imports/preview": return self.preview_import(payload, db, user["organization_id"])
return self.send_json(404, {"error": "not_found"})
finally: db.close() finally: db.close()
def create_suppression(self, payload): def login(self, payload):
email = str(payload.get("email", "")).strip().lower(); password = str(payload.get("password", "")); db = self.db()
try:
user = db.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
if not user or not verify_password(password, user["password_hash"], user["password_salt"]): return self.send_json(401, {"error": "invalid_credentials"})
token = secrets.token_urlsafe(32); expires = datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)
db.execute("INSERT INTO sessions (user_id,token_hash,expires_at) VALUES (?,?,?)", (user["id"], hashlib.sha256(token.encode()).hexdigest(), expires.replace(microsecond=0).isoformat()))
db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "login")); db.commit()
return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": user["organization_id"]}, {"Set-Cookie": self.auth_cookie(token, int(timedelta(days=SESSION_DAYS).total_seconds()))})
finally: db.close()
def create_business(self, payload, db, org):
if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"})
business = normalize_business(payload); suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))]
if is_suppressed(business, suppressions): return self.send_json(409, {"error": "suppressed"})
fields = [(c, business[c]) for c in ("website_domain", "email", "phone") if business[c]]
if fields and db.execute("SELECT id FROM businesses WHERE organization_id = ? AND (" + " OR ".join(f"{c} = ?" for c, _ in fields) + ")", [org] + [v for _, v in fields]).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 (?,?,?,?,?,?,?,?,?,?,?)", (org,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 = ? AND organization_id = ?", (cur.lastrowid, org)).fetchone()))
def create_suppression(self, payload, db, org):
kind, value = payload.get("kind"), str(payload.get("value", "")).strip().lower() 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"}) if kind not in {"email", "domain", "phone"} or not value: return self.send_json(400, {"error": "invalid_suppression"})
db = self.db() try: db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (org, kind, value)); db.commit()
try: except sqlite3.IntegrityError: pass
try: return self.send_json(201, dict(db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (org, kind, value)).fetchone()))
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): def preview_import(self, payload, db, org):
rows = payload.get("rows", []) rows = payload.get("rows", [])
if not isinstance(rows, list): return self.send_json(400, {"error": "rows_required"}) 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()]) normalized = deduplicate_businesses([r for r in rows if isinstance(r, dict) and str(r.get("name", "")).strip()])
db = self.db() suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))]; existing = [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ?", (org,))]; seen = set(); accepted, duplicate, suppressed = [], 0, 0
try: existing_keys = {deduplication_key(x) for x in existing}
suppressions = suppression_rows(db); existing = existing_businesses(db); seen = set() for b in normalized:
accepted, duplicate, suppressed = [], 0, 0 key = deduplication_key(b)
for b in normalized: if is_suppressed(b, suppressions): suppressed += 1
key = deduplication_key(b) elif key in existing_keys or key in seen: duplicate += 1
if is_suppressed(b, suppressions): suppressed += 1 else: seen.add(key); accepted.append(b)
elif (key in {deduplication_key(x) for x in existing} or key in seen): duplicate += 1 return self.send_json(200, {"accepted": len(accepted), "duplicates": duplicate + len(rows) - len(normalized), "suppressed": suppressed, "rows": accepted})
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 log_message(self, *_): pass
def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"): def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"):
server = ThreadingHTTPServer((host, port), ApiHandler) server = ThreadingHTTPServer((host, port), ApiHandler); setattr(server, "db_path", db_path); connect(db_path).close(); return server
server.db_path = db_path
connect(db_path).close()
return server
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Prospect Platform API") 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)
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() try: server.serve_forever()
except KeyboardInterrupt: pass except KeyboardInterrupt: pass
finally: server.server_close() finally: server.server_close()
+38
View File
@@ -1,4 +1,41 @@
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL REFERENCES organizations(id),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('viewer','owner','admin','researcher')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT REFERENCES organizations(id),
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_log(organization_id);
CREATE TABLE IF NOT EXISTS businesses ( CREATE TABLE IF NOT EXISTS businesses (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL, organization_id TEXT NOT NULL,
@@ -18,6 +55,7 @@ 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_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_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 UNIQUE INDEX IF NOT EXISTS uq_business_phone ON businesses(organization_id, phone) WHERE phone <> '';
CREATE TABLE IF NOT EXISTS suppressions ( CREATE TABLE IF NOT EXISTS suppressions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL, organization_id TEXT NOT NULL,
+56 -5
View File
@@ -1,31 +1,82 @@
import json import json
import os
import sqlite3
import threading import threading
import unittest import unittest
from http.client import HTTPConnection from http.client import HTTPConnection
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from app.main import create_server from app.main import create_server, hash_password
class ApiSmokeTests(unittest.TestCase): class ApiSmokeTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = TemporaryDirectory() self.tmp = TemporaryDirectory()
self.server = create_server("127.0.0.1", 0, self.tmp.name + "/test.db") self.old_env = {key: os.environ.get(key) for key in ("BOOTSTRAP_ADMIN_EMAIL", "BOOTSTRAP_ADMIN_PASSWORD")}
os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "owner@example.test"
os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "development-password"
self.db_path = self.tmp.name + "/test.db"
self.server = create_server("127.0.0.1", 0, self.db_path)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start() self.thread.start()
self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3) self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)
self.cookie = None
status, _ = self.request("POST", "/api/v1/auth/login", {"email": "owner@example.test", "password": "development-password"})
self.assertEqual(status, 200)
def tearDown(self): def tearDown(self):
self.server.shutdown() self.server.shutdown()
self.server.server_close() self.server.server_close()
self.thread.join(timeout=2) self.thread.join(timeout=2)
for key, value in self.old_env.items():
if value is None: os.environ.pop(key, None)
else: os.environ[key] = value
self.tmp.cleanup() self.tmp.cleanup()
def request(self, method, path, payload=None): def request(self, method, path, payload=None, cookie=True):
body = json.dumps(payload).encode() if payload is not None else 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 {}) headers = {"Content-Type": "application/json"} if body else {}
if cookie and self.cookie: headers["Cookie"] = self.cookie
self.conn.request(method, path, body, headers)
response = self.conn.getresponse() response = self.conn.getresponse()
return response.status, json.loads(response.read()) set_cookie = response.getheader("Set-Cookie")
if set_cookie and "session=" in set_cookie:
self.cookie = set_cookie.split(";", 1)[0]
return response.status, json.loads(response.read() or b"{}")
def test_auth_login_me_logout_and_protected_route(self):
self.assertEqual(self.request("GET", "/api/v1/auth/me")[0], 200)
self.assertEqual(self.request("POST", "/api/v1/auth/logout")[0], 200)
self.assertEqual(self.request("GET", "/api/v1/dashboard/summary")[0], 401)
def test_viewer_cannot_mutate(self):
password_hash, salt = hash_password("viewer-password")
db = sqlite3.connect(self.db_path)
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("demo-tenant", "viewer@example.test", password_hash, salt, "viewer"))
db.commit(); db.close()
self.cookie = None
self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "viewer@example.test", "password": "viewer-password"})[0], 200)
self.assertEqual(self.request("POST", "/api/v1/businesses", {"name": "Nope"})[0], 403)
def test_cross_organization_businesses_are_isolated(self):
password_hash, salt = hash_password("other-password")
db = sqlite3.connect(self.db_path)
db.execute("INSERT INTO organizations (id,name) VALUES (?,?)", ("other-tenant", "Other"))
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("other-tenant", "other@example.test", password_hash, salt, "owner"))
db.commit(); db.close()
self.cookie = None
self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "other@example.test", "password": "other-password"})[0], 200)
self.assertEqual(self.request("POST", "/api/v1/businesses", {"name": "Other Co", "website": "https://other.test"})[0], 201)
self.assertEqual(self.request("GET", "/api/v1/businesses")[1]["organization_id"], "other-tenant")
self.cookie = None
self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "owner@example.test", "password": "development-password"})[0], 200)
status, businesses = self.request("GET", "/api/v1/businesses")
self.assertEqual(status, 200)
self.assertEqual(businesses["items"], [])
def test_missing_auth_is_rejected(self):
self.cookie = None
self.assertEqual(self.request("GET", "/api/v1/businesses", cookie=False)[0], 401)
def test_health_create_get_summary_and_import_preview(self): def test_health_create_get_summary_and_import_preview(self):
self.assertEqual(self.request("GET", "/api/v1/health/live")[0], 200) self.assertEqual(self.request("GET", "/api/v1/health/live")[0], 200)
+27 -13
View File
@@ -3,16 +3,9 @@
'use strict'; 'use strict';
const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, ''); const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, '');
const endpoint = (path) => `${API_BASE}${path}`; 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 prospects = [];
let selectedId = null; let selectedId = null;
let currentUser = null;
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 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 scoreClass = (score) => score >= 80 ? 'high' : score >= 60 ? 'medium' : 'low';
@@ -25,6 +18,24 @@
}; };
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 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('_',' ')); const labelFactor = (f) => ({named_business:'Named business',business_site:'Business site',email:'Email found',phone:'Phone found',description:'Description'}[f] || String(f).replaceAll('_',' '));
async function request(path, options = {}) {
const response = await fetch(endpoint(path), {...options, credentials:'include'});
if (response.status === 401) { showLogin('Your session has expired. Please sign in again.'); throw new Error('unauthorized'); }
return response;
}
function showLogin(message = '') {
currentUser = null;
$('dashboardShell').hidden = true; $('loginScreen').hidden = false;
$('loginMessage').textContent = message; $('loginMessage').className = `form-message${message ? ' error' : ''}`;
}
function showDashboard(user) {
currentUser = user || {};
const name = currentUser.name || currentUser.full_name || currentUser.email || 'Workspace member';
const role = currentUser.role || currentUser.roles?.[0] || 'Member';
$('userIdentity').textContent = `${name} · ${role}`;
$('userAvatar').textContent = name.split(/\s+/).map(x => x[0]).join('').slice(0,2).toUpperCase();
$('loginScreen').hidden = true; $('dashboardShell').hidden = false;
}
function renderMetrics(summary) { function renderMetrics(summary) {
const total = Number(summary?.businesses ?? summary?.total ?? prospects.length); const total = Number(summary?.businesses ?? summary?.total ?? prospects.length);
const high = prospects.filter(p => scoreFor(p) >= 80).length; const high = prospects.filter(p => scoreFor(p) >= 80).length;
@@ -48,13 +59,16 @@
$('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>`:''}`; $('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() { async function loadData() {
$('apiStatus').textContent = API_BASE ? '● Connecting…' : '● Demo data'; $('apiStatus').textContent='● Connecting…'; $('apiStatus').classList.remove('live');
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'; } try { const [listRes, summaryRes] = await Promise.all([request('/api/v1/businesses'), request('/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) { if (error.message === 'unauthorized') { return; } prospects=[]; renderMetrics(null); $('apiStatus').textContent='● API unavailable'; }
renderRows(); 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'; } } async function addProspect(event) { event.preventDefault(); const data=Object.fromEntries(new FormData(event.currentTarget).entries()); const msg=$('formMessage'); try { const res=await request('/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) { if(e.message !== 'unauthorized') { 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 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>`; } 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())); async function login(event) { event.preventDefault(); const form=event.currentTarget, message=$('loginMessage'); const data=Object.fromEntries(new FormData(form).entries()); message.textContent='Signing in…'; message.className='form-message'; try { const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)}); const body=await res.json().catch(()=>({})); if(!res.ok) throw new Error(body.error || 'Invalid email or password.'); await bootstrap(); } catch(e) { if(e.message !== 'unauthorized') { message.textContent=e.message; message.className='form-message error'; } } }
loadData(); async function logout() { try { await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'}); } finally { showLogin('You have been signed out.'); $('loginForm').reset(); } }
async function bootstrap() { try { const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'}); if(res.status===401) { showLogin(); return; } if(!res.ok) throw new Error('Could not verify session.'); const user=await res.json(); showDashboard(user.user || user); await loadData(); } catch(e) { if(e.message !== 'unauthorized') showLogin('Unable to connect to the workspace. Try again.'); } }
$('loginForm').addEventListener('submit',login); $('logoutBtn').addEventListener('click',logout); $('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()));
bootstrap();
})(); })();
+17 -2
View File
@@ -8,7 +8,22 @@
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
<body> <body>
<div class="app-shell"> <section class="login-screen" id="loginScreen" aria-labelledby="loginTitle">
<div class="login-card">
<a class="brand login-brand" href="#login" aria-label="ProspectOS home"><span class="brand-mark"></span><span>Prospect<span class="brand-light">OS</span></span></a>
<p class="eyebrow">WORKSPACE ACCESS</p>
<h1 id="loginTitle">Welcome back</h1>
<p class="login-subtitle">Sign in to review your evidence-led growth pipeline.</p>
<form id="loginForm" novalidate>
<label>Email address<input id="loginEmail" name="email" type="email" autocomplete="username" placeholder="you@company.com" required></label>
<label>Password<input id="loginPassword" name="password" type="password" autocomplete="current-password" placeholder="Enter your password" required></label>
<p id="loginMessage" class="form-message" role="alert" aria-live="polite"></p>
<button class="button primary login-submit" type="submit">Sign in <span aria-hidden="true"></span></button>
</form>
<p class="login-note">Use the credentials configured for your workspace.</p>
</div>
</section>
<div class="app-shell" id="dashboardShell" hidden>
<aside class="sidebar"> <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> <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"> <nav aria-label="Primary navigation">
@@ -19,7 +34,7 @@
<div class="sidebar-foot"><div class="live-dot"></div><div><strong>Workspace live</strong><small>Data sync is healthy</small></div></div> <div class="sidebar-foot"><div class="live-dot"></div><div><strong>Workspace live</strong><small>Data sync is healthy</small></div></div>
</aside> </aside>
<main class="main" id="top"> <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> <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">Connecting…</span><span class="user-identity" id="userIdentity"></span><button class="icon-button" aria-label="Notifications"></button><button class="logout-button" id="logoutBtn" type="button">Log out</button><div class="avatar" id="userAvatar">?</div></div></header>
<div class="content"> <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="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"> <section class="metrics" aria-label="Dashboard metrics">
+19 -1
View File
@@ -1 +1,19 @@
<!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> <!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 frame=document.querySelector('#app');
frame.onload=async()=>{const d=frame.contentDocument; const js=await fetch('app.js').then(r=>r.text()); const checks=[
['Login screen',()=>!!d.querySelector('#loginScreen')],
['Email/password login fields',()=>!!d.querySelector('#loginEmail')&&!!d.querySelector('#loginPassword')],
['Dashboard starts protected',()=>d.querySelector('#dashboardShell').hidden],
['Logout and user display',()=>!!d.querySelector('#logoutBtn')&&!!d.querySelector('#userIdentity')],
['All API requests include cookies',()=>[...js.matchAll(/fetch\([^;]+/g)].every(m=>m[0].includes("credentials:'include'"))],
['No hardcoded credentials or demo fallback',()=>!js.includes('demoProspects')&&!/password.{0,30}['\"][^'\"]+['\"]/.test(js)],
['Safety copy and blocked outreach preserved',()=>js.includes('disabled-action')&&js.includes('Suppressed records cannot be contacted.')&&js.includes('Review this prospect before outreach is available.')],
['No outreach/send controls',()=>!Array.from(d.querySelectorAll('button')).some(x=>/outreach|send/i.test(x.textContent)&&!x.disabled)]
]; let passed=0; document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test();if(ok)passed++;return `<li class="${ok?'pass':'fail'}">${ok?'PASS':'FAIL'} — ${name}</li>`}).join(''); document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;};
</script>
+1 -1
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -11,6 +11,9 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080}
DATA_DIR: /data DATA_DIR: /data
# Optional first-run admin bootstrap; leave unset after provisioning.
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
AUTOMATED_OUTREACH_ENABLED: "false" AUTOMATED_OUTREACH_ENABLED: "false"
ports: ports:
- "${API_PORT:-8000}:8000" - "${API_PORT:-8000}:8000"
+6 -4
View File
@@ -16,18 +16,20 @@ The expected health endpoints are:
- API: `GET http://localhost:8000/api/v1/health/live` - API: `GET http://localhost:8000/api/v1/health/live`
- Web: `GET http://localhost:8080/healthz` - Web: `GET http://localhost:8080/healthz`
A service is ready only when Compose reports `healthy`; container running status alone is insufficient. The Compose environment explicitly carries `AUTOMATED_OUTREACH_ENABLED=false` as an operational safety setting. A service is ready only when Compose reports `healthy`; container running status alone is insufficient. Health checks call public liveness endpoints and must remain unauthenticated—do not add a session requirement to `/api/v1/health/live` or `/healthz`. The Compose environment explicitly carries `AUTOMATED_OUTREACH_ENABLED=false` as an operational safety setting.
## Configuration and deployment ## 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. Copy `.env.example` for local development. Production values must be supplied by the deployment environment, never committed. `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWORD` are optional API environment variables for first-run admin provisioning only; set them together through a secret store or protected deployment environment, remove them immediately after successful bootstrap, and rotate the password. Do not put real values in Compose files, CI variables visible to logs, images, or committed `.env` files.
For production, use Argon2id for password hashing and require MFA for administrator accounts. Configure TLS before enabling `Secure` session cookies. Local Compose uses HTTP, so browser testing of the production `Secure` cookie behavior requires an HTTPS staging environment. Treat session cookies as bearer credentials: protect state-changing routes with CSRF controls, expire/revoke sessions, and never print cookie values in logs.
Before deployment: Before deployment:
1. Run `docker compose config` and review the rendered configuration. 1. Run `docker compose config` and review the rendered configuration (optional bootstrap values should be empty in CI and local validation).
2. Build from a reviewed commit and scan the resulting images. 2. Build from a reviewed commit and scan the resulting images.
3. Restrict host/network exposure at the ingress/firewall. 3. Restrict host/network exposure at the ingress/firewall.
4. Verify both health checks and review logs for unexpected errors or sensitive data. 4. Verify both unauthenticated health checks and review logs for unexpected errors or sensitive data.
5. Record the image digest and configuration revision for rollback. 5. Record the image digest and configuration revision for rollback.
## Data, backups, and retention ## Data, backups, and retention
+12 -9
View File
@@ -4,18 +4,21 @@
- **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. - **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. - No credentials are committed. `.env.example` contains non-secret names and local defaults only.
- Authentication uses server-side sessions for browser clients. The session identifier is carried in an `HttpOnly` cookie; logout/revocation must invalidate the server-side session. Health endpoints are deliberately public and must remain usable without a session.
- 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. - 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. - The stdlib API remains a small MVP security boundary. Authentication/session handling does not by itself provide authorization, CSRF protection, rate limiting, MFA, or a complete audit log.
## Known limitations before production ## 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. 1. **Password storage:** production passwords must be hashed with Argon2id using a reviewed cost/memory/parallelism policy. Never store plaintext or reversible passwords, and never log bootstrap credentials. Rehash on login when the policy changes.
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. 2. **MFA:** require phishing-resistant or TOTP MFA for administrator accounts in production, including the bootstrap admin before granting ongoing administrative access. Define recovery, enrollment, reset, and revocation procedures; do not treat a password-only bootstrap as production-ready.
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. 3. **Authentication and authorization:** enforce authorization server-side on every protected route, rotate/regenerate sessions at login and privilege changes, expire idle/absolute sessions, revoke on logout/password reset, and test tenant isolation. The bootstrap variables are one-time provisioning inputs, not a standing authentication mechanism.
4. **Input/output safety:** validate schema and content types, bound request sizes, parameterize database queries, escape output, and avoid logging contact data or secrets. 4. **Cookies and CSRF:** use `HttpOnly`, `Secure` (production HTTPS), and an appropriate `SameSite` policy. `Secure` cookies cannot be exercised over the local HTTP Compose URLs, and `SameSite` is defense-in-depth—not a complete CSRF control. Browser state-changing endpoints require CSRF tokens (or a rigorously reviewed equivalent); do not rely on CORS or cookie flags alone.
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. 5. **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.
6. **Transport and perimeter:** terminate TLS at a trusted ingress, restrict exposed ports, add network policy, and place admin surfaces behind appropriate access controls. 6. **Input/output safety:** validate schema and content types, bound request sizes, parameterize database queries, escape output, and avoid logging contact data or secrets.
7. **Data protection:** define retention and deletion rules for prospect/contact data, restrict volume access, encrypt backups, and maintain an access/audit trail. 7. **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. Remove bootstrap variables after first-run provisioning.
8. **Transport and perimeter:** terminate TLS at a trusted ingress, restrict exposed ports, add network policy, and place admin surfaces behind appropriate access controls.
9. **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 ## Source and contact policy
@@ -23,6 +26,6 @@ Treat discovered business information as potentially personal or copyrighted dat
## CI/dependency hygiene ## 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. Pin or review base-image and dependency updates, scan images before release, use least-privilege GitHub tokens, and avoid printing environment values. CI may validate Compose with empty optional bootstrap variables and call unauthenticated health checks; it is not a substitute for Argon2id parameter review, MFA testing, or a security assessment.
Report vulnerabilities privately to the repository maintainers; do not include live credentials or personal data in an issue. Report vulnerabilities privately to the repository maintainers; do not include live credentials or personal data in an issue.