add authenticated tenant-scoped sessions
This commit is contained in:
+120
-79
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
@@ -17,6 +21,23 @@ else:
|
||||
|
||||
ORGANIZATION_ID = "demo-tenant"
|
||||
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:
|
||||
@@ -24,6 +45,14 @@ def connect(db_path: str) -> sqlite3.Connection:
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA foreign_keys = ON")
|
||||
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
|
||||
|
||||
|
||||
@@ -33,24 +62,18 @@ def row_json(row: sqlite3.Row) -> dict:
|
||||
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):
|
||||
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")
|
||||
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-Credentials", "true")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
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.end_headers()
|
||||
self.wfile.write(body)
|
||||
@@ -63,116 +86,134 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def db(self):
|
||||
return connect(self.server.db_path)
|
||||
def db(self): return connect(getattr(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-Credentials", "true")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
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):
|
||||
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})
|
||||
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:
|
||||
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":
|
||||
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]})
|
||||
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": 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":
|
||||
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:
|
||||
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]})
|
||||
like = f"%{query}%"; sql += " AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?)"; params += [like] * 3
|
||||
rows = db.execute(sql + " ORDER BY score DESC, id", params).fetchall()
|
||||
return self.send_json(200, {"organization_id": org, "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()
|
||||
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(404, {"error": "not_found"})
|
||||
finally:
|
||||
db.close()
|
||||
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)
|
||||
if path == "/api/v1/auth/login": return self.login(self.read_json())
|
||||
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()))
|
||||
user = self.require_auth(db)
|
||||
if not user: return
|
||||
if path == "/api/v1/auth/logout":
|
||||
cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", "")); token = cookie.get("session")
|
||||
if token: db.execute("DELETE FROM sessions WHERE token_hash = ?", (hashlib.sha256(token.value.encode()).hexdigest(),))
|
||||
db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "logout")); db.commit()
|
||||
return self.send_json(200, {"ok": True}, {"Set-Cookie": self.auth_cookie("", 0)})
|
||||
if user["role"] not in MUTATING_ROLES: return self.send_json(403, {"error": "forbidden"})
|
||||
payload = self.read_json()
|
||||
if path == "/api/v1/businesses": return self.create_business(payload, db, user["organization_id"])
|
||||
if path == "/api/v1/suppressions": return self.create_suppression(payload, db, user["organization_id"])
|
||||
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()
|
||||
|
||||
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()
|
||||
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()
|
||||
try: db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (org, kind, value)); db.commit()
|
||||
except sqlite3.IntegrityError: pass
|
||||
return self.send_json(201, dict(db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (org, kind, value)).fetchone()))
|
||||
|
||||
def preview_import(self, payload):
|
||||
def preview_import(self, payload, db, org):
|
||||
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()
|
||||
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
|
||||
existing_keys = {deduplication_key(x) for x in existing}
|
||||
for b in normalized:
|
||||
key = deduplication_key(b)
|
||||
if is_suppressed(b, suppressions): suppressed += 1
|
||||
elif key in existing_keys 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})
|
||||
|
||||
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
|
||||
server = ThreadingHTTPServer((host, port), ApiHandler); setattr(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)
|
||||
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()
|
||||
|
||||
@@ -1,4 +1,41 @@
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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_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,
|
||||
|
||||
@@ -1,31 +1,82 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import unittest
|
||||
from http.client import HTTPConnection
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from app.main import create_server
|
||||
from app.main import create_server, hash_password
|
||||
|
||||
|
||||
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.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.start()
|
||||
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):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
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()
|
||||
|
||||
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
|
||||
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()
|
||||
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):
|
||||
self.assertEqual(self.request("GET", "/api/v1/health/live")[0], 200)
|
||||
|
||||
+27
-13
@@ -3,16 +3,9 @@
|
||||
'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;
|
||||
let currentUser = null;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
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 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) {
|
||||
const total = Number(summary?.businesses ?? summary?.total ?? prospects.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>`:''}`;
|
||||
}
|
||||
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'; }
|
||||
$('apiStatus').textContent='● Connecting…'; $('apiStatus').classList.remove('live');
|
||||
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();
|
||||
}
|
||||
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 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();
|
||||
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'; } } }
|
||||
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
@@ -8,7 +8,22 @@
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<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">
|
||||
<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">
|
||||
@@ -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>
|
||||
</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>
|
||||
<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">
|
||||
<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">
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user