2026-09-02 17:38:50 +02:00
from __future__ import annotations
import argparse
2026-09-02 17:45:57 +02:00
import hashlib
2026-09-02 17:38:50 +02:00
import json
import os
2026-09-02 17:45:57 +02:00
import secrets
2026-09-02 17:38:50 +02:00
import sqlite3
import sys
2026-09-02 17:45:57 +02:00
from datetime import datetime , timedelta , timezone
from http.cookies import SimpleCookie
2026-09-02 17:38:50 +02:00
from http.server import BaseHTTPRequestHandler , ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs , urlparse
if __package__ in ( None , "" ):
sys . path . insert ( 0 , str ( Path ( __file__ ) . resolve () . parents [ 1 ]))
from app.domain import deduplication_key , deduplicate_businesses , is_suppressed , normalize_business , score_business
else :
from .domain import deduplication_key , deduplicate_businesses , is_suppressed , normalize_business , score_business
ORGANIZATION_ID = "demo-tenant"
SCHEMA = Path ( __file__ ) . resolve () . parents [ 1 ] / "schema.sql"
2026-09-02 17:45:57 +02:00
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
2026-09-02 17:38:50 +02:00
def connect ( db_path : str ) -> sqlite3 . Connection :
db = sqlite3 . connect ( db_path )
db . row_factory = sqlite3 . Row
db . execute ( "PRAGMA foreign_keys = ON" )
db . executescript ( SCHEMA . read_text ())
2026-09-02 17:45:57 +02:00
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 ()
2026-09-02 17:38:50 +02:00
return db
def row_json ( row : sqlite3 . Row ) -> dict :
result = dict ( row )
result [ "score_factors" ] = json . loads ( result . pop ( "score_factors" , "[]" ))
return result
class ApiHandler ( BaseHTTPRequestHandler ):
server_version = "ProspectPlatform/0.1"
2026-09-02 17:45:57 +02:00
def send_json ( self , status : int , payload : dict | list , extra_headers : dict [ str , str ] | None = None ):
2026-09-02 17:38:50 +02:00
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" ))
2026-09-02 17:45:57 +02:00
self . send_header ( "Access-Control-Allow-Credentials" , "true" )
2026-09-02 17:38:50 +02:00
self . send_header ( "Access-Control-Allow-Methods" , "GET, POST, OPTIONS" )
self . send_header ( "Access-Control-Allow-Headers" , "Content-Type" )
2026-09-02 17:45:57 +02:00
for key , value in ( extra_headers or {}) . items (): self . send_header ( key , value )
2026-09-02 17:38:50 +02:00
self . send_header ( "Content-Length" , str ( len ( body )))
self . end_headers ()
self . wfile . write ( body )
def read_json ( self ) -> dict :
try :
length = int ( self . headers . get ( "Content-Length" , "0" ))
value = json . loads ( self . rfile . read ( length ) or b " {} " )
return value if isinstance ( value , dict ) else {}
except ( ValueError , json . JSONDecodeError ):
return {}
2026-09-02 17:45:57 +02:00
def db ( self ): return connect ( getattr ( self . server , "db_path" ))
2026-09-02 17:38:50 +02:00
def do_OPTIONS ( self ):
self . send_response ( 204 )
self . send_header ( "Access-Control-Allow-Origin" , os . environ . get ( "CORS_ORIGINS" , "http://localhost:8080" ))
2026-09-02 17:45:57 +02:00
self . send_header ( "Access-Control-Allow-Credentials" , "true" )
2026-09-02 17:38:50 +02:00
self . send_header ( "Access-Control-Allow-Methods" , "GET, POST, OPTIONS" )
self . send_header ( "Access-Control-Allow-Headers" , "Content-Type" )
self . end_headers ()
2026-09-02 17:45:57 +02:00
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"
2026-09-02 17:38:50 +02:00
def do_GET ( self ):
2026-09-02 17:45:57 +02:00
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 })
2026-09-02 17:38:50 +02:00
db = self . db ()
try :
2026-09-02 17:45:57 +02:00
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 ]})
2026-09-02 17:38:50 +02:00
if path == "/api/v1/dashboard/summary" :
2026-09-02 17:45:57 +02:00
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 ]})
2026-09-02 17:38:50 +02:00
if path == "/api/v1/businesses" :
2026-09-02 17:45:57 +02:00
query = parse_qs ( parsed . query ) . get ( "q" , [ "" ])[ 0 ] . strip (); params = [ org ]
sql = "SELECT * FROM businesses WHERE organization_id = ?"
2026-09-02 17:38:50 +02:00
if query :
2026-09-02 17:45:57 +02:00
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 ]})
2026-09-02 17:38:50 +02:00
if path . startswith ( "/api/v1/businesses/" ):
ident = path . rsplit ( "/" , 1 )[ 1 ]
if not ident . isdigit (): return self . send_json ( 404 , { "error" : "not_found" })
2026-09-02 17:45:57 +02:00
row = db . execute ( "SELECT * FROM businesses WHERE id = ? AND organization_id = ?" , ( int ( ident ), org )) . fetchone ()
2026-09-02 17:38:50 +02:00
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" })
2026-09-02 17:45:57 +02:00
finally : db . close ()
2026-09-02 17:38:50 +02:00
def do_POST ( self ):
path = urlparse ( self . path ) . path . rstrip ( "/" )
2026-09-02 17:45:57 +02:00
if path == "/api/v1/auth/login" : return self . login ( self . read_json ())
2026-09-02 17:38:50 +02:00
db = self . db ()
try :
2026-09-02 17:45:57 +02:00
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" })
2026-09-02 17:38:50 +02:00
finally : db . close ()
2026-09-02 17:45:57 +02:00
def login ( self , payload ):
email = str ( payload . get ( "email" , "" )) . strip () . lower (); password = str ( payload . get ( "password" , "" )); db = self . db ()
2026-09-02 17:38:50 +02:00
try :
2026-09-02 17:45:57 +02:00
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 ()))})
2026-09-02 17:38:50 +02:00
finally : db . close ()
2026-09-02 17:45:57 +02:00
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" })
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 , db , org ):
2026-09-02 17:38:50 +02:00
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 ()])
2026-09-02 17:45:57 +02:00
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 })
2026-09-02 17:38:50 +02:00
def log_message ( self , * _ ): pass
def create_server ( host = "127.0.0.1" , port = 8000 , db_path = "prospects.db" ):
2026-09-02 17:45:57 +02:00
server = ThreadingHTTPServer (( host , port ), ApiHandler ); setattr ( server , "db_path" , db_path ); connect ( db_path ) . close (); return server
2026-09-02 17:38:50 +02:00
if __name__ == "__main__" :
2026-09-02 17:45:57 +02:00
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 )
2026-09-02 17:38:50 +02:00
try : server . serve_forever ()
except KeyboardInterrupt : pass
finally : server . server_close ()