Compare commits

...
2 Commits
Author SHA1 Message Date
Marco0300 7a010fe865 Add secure admin AI provider configuration
CI / compose (push) Successful in 12m11s
2026-09-03 21:15:42 +02:00
Marco0300 d07dc77ca1 [verified] add admin AI provider settings UI 2026-09-03 21:15:00 +02:00
12 changed files with 316 additions and 27 deletions
+9 -5
View File
@@ -12,11 +12,15 @@ BOOTSTRAP_ADMIN_EMAIL=
BOOTSTRAP_ADMIN_PASSWORD= BOOTSTRAP_ADMIN_PASSWORD=
# Hard safety default; this release has no delivery capability. # Hard safety default; this release has no delivery capability.
AUTOMATED_OUTREACH_ENABLED=false AUTOMATED_OUTREACH_ENABLED=false
# Optional criteria-first Nous Portal AI web research. The model uses strict # Runtime provider configuration is managed in the authenticated admin API:
# web_search/scrape_website tools through an approved Firecrawl-compatible API. # POST /api/v1/admin/ai-provider-config. It is stored per organization with
# Only structured HTTPS targets are returned; the server crawler validates and # encrypted credentials in SQLite and a generated 0600 key at /data/provider-config.key.
# persists evidence. Both credentials are server-side only. # On startup, a database row takes precedence over every provider environment
# AI_RESEARCH_PROVIDER=nous_portal # variable. Environment values are bootstrap fallback only when no DB row exists;
# they are never copied into API responses. Connectivity tests use bounded GET
# requests only and report outbound_calls=false (no outreach is implemented).
# The following variables are legacy/bootstrap fallback values only.
AI_RESEARCH_PROVIDER=
# NOUS_MODEL=Hermes-4-405B # NOUS_MODEL=Hermes-4-405B
# NOUS_BASE_URL=https://inference-api.nousresearch.com/v1 # NOUS_BASE_URL=https://inference-api.nousresearch.com/v1
# NOUS_ALLOWED_HOSTS=inference-api.nousresearch.com # NOUS_ALLOWED_HOSTS=inference-api.nousresearch.com
+20
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import json import json
import os import os
import re import re
import sqlite3
from urllib.parse import urlparse from urllib.parse import urlparse
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@@ -43,7 +44,26 @@ def _safe_endpoint(value: str, allowed: set[str]) -> str:
return value.rstrip("/") return value.rstrip("/")
_DB_PATH = ""
_DB_ORG = "demo-tenant"
def configure_db(path: str, organization_id: str = "demo-tenant") -> None:
global _DB_PATH, _DB_ORG
_DB_PATH, _DB_ORG = path, organization_id
def _config(): def _config():
if _DB_PATH:
try:
db = sqlite3.connect(_DB_PATH); db.row_factory = sqlite3.Row
row = db.execute("SELECT * FROM ai_remote_provider_configs WHERE organization_id=?", (_DB_ORG,)).fetchone(); db.close()
if row:
from .provider_config import decrypt
credentials = json.loads(decrypt(row["credentials_ciphertext"])) if row["credentials_ciphertext"] else {}
return {"provider": row["provider"], "model": row["model"], "nous_url": row["nous_base_url"], "nous_allowed": {urlparse(row["nous_base_url"]).hostname}, "nous_key": credentials.get("nous_api_key", ""), "firecrawl_url": row["firecrawl_base_url"], "firecrawl_allowed": {urlparse(row["firecrawl_base_url"]).hostname}, "firecrawl_key": credentials.get("firecrawl_api_key", "")}
except Exception:
return {"provider": "", "model": "", "endpoint": "", "allowed": set(), "api_key": ""}
provider = os.environ.get("AI_RESEARCH_PROVIDER", "").strip().lower() provider = os.environ.get("AI_RESEARCH_PROVIDER", "").strip().lower()
# Nous uses its conventional key directly; no gateway or key translation is needed. # Nous uses its conventional key directly; no gateway or key translation is needed.
nous_key = os.environ.get("NOUS_API_KEY", "").strip() nous_key = os.environ.get("NOUS_API_KEY", "").strip()
+40 -2
View File
@@ -15,9 +15,10 @@ if __package__ in (None, ""):
from app.scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION from app.scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION
from app.ai_assistance import generate as generate_ai, input_fingerprint, provider_status, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS from app.ai_assistance import generate as generate_ai, input_fingerprint, provider_status, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS
from app.discovery import discover as scoped_discover from app.discovery import discover as scoped_discover
from app.ai_research import provider_status as ai_research_provider_status, validate_criteria as validate_ai_research_criteria, AIResearchConfigError from app.ai_research import provider_status as ai_research_provider_status, configure_db as configure_ai_research_db, validate_criteria as validate_ai_research_criteria, AIResearchConfigError
from app.search_provider import provider_status as search_provider_status from app.search_provider import provider_status as search_provider_status
from app.config import load_config from app.config import load_config
from app.provider_config import validate_payload as validate_remote_provider, encrypt as encrypt_provider_secret, decrypt as decrypt_provider_secret, safe_status as remote_provider_status, test_connectivity as test_remote_connectivity
else: else:
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone, match_businesses from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone, match_businesses
from .sources import adapter_for, contains_secret from .sources import adapter_for, contains_secret
@@ -27,9 +28,10 @@ else:
from .scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION from .scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION
from .ai_assistance import generate as generate_ai, input_fingerprint, provider_status, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS from .ai_assistance import generate as generate_ai, input_fingerprint, provider_status, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS
from .discovery import discover as scoped_discover from .discovery import discover as scoped_discover
from .ai_research import provider_status as ai_research_provider_status, validate_criteria as validate_ai_research_criteria, AIResearchConfigError from .ai_research import provider_status as ai_research_provider_status, configure_db as configure_ai_research_db, validate_criteria as validate_ai_research_criteria, AIResearchConfigError
from .search_provider import provider_status as search_provider_status from .search_provider import provider_status as search_provider_status
from .config import load_config from .config import load_config
from .provider_config import validate_payload as validate_remote_provider, encrypt as encrypt_provider_secret, decrypt as decrypt_provider_secret, safe_status as remote_provider_status, test_connectivity as test_remote_connectivity
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 SESSION_DAYS = 7
@@ -476,7 +478,39 @@ class ApiHandler(BaseHTTPRequestHandler):
reasons.append("network_send_disabled"); self.audit(db, user, "outreach_draft.send_blocked", f"{did}:network_send_disabled"); db.commit() reasons.append("network_send_disabled"); self.audit(db, user, "outreach_draft.send_blocked", f"{did}:network_send_disabled"); db.commit()
return self.send_json(409, {"status": "blocked", "blocked_reasons": reasons, "network_send": False, "id": did}) return self.send_json(409, {"status": "blocked", "blocked_reasons": reasons, "network_send": False, "id": did})
def remote_ai_provider_config(self, db, user, payload=None, connectivity=False):
org = user["organization_id"]
row = db.execute("SELECT * FROM ai_remote_provider_configs WHERE organization_id=?", (org,)).fetchone()
if connectivity:
if user["role"] not in {"owner", "admin"}:
return self.send_json(403, {"error": "forbidden"})
return self.send_json(200, test_remote_connectivity(row))
if payload is not None:
if user["role"] not in {"owner", "admin"}:
return self.send_json(403, {"error": "forbidden"})
try:
config = validate_remote_provider(payload)
credentials = dict(config["credentials"])
if row:
try: old = json.loads(decrypt_provider_secret(row["credentials_ciphertext"])) if row["credentials_ciphertext"] else {}
except Exception: old = {}
for name in ("nous_api_key", "firecrawl_api_key"):
if name not in credentials and name in old: credentials[name] = old[name]
if config["enabled"] and any(name not in credentials for name in ("nous_api_key", "firecrawl_api_key")):
return self.send_json(400, {"error": "provider_credentials_required"})
ciphertext = encrypt_provider_secret(json.dumps(credentials, sort_keys=True)) if credentials else ""
fingerprint = hashlib.sha256(json.dumps(credentials, sort_keys=True).encode()).hexdigest() if credentials else ""
except (ValueError, TypeError, RuntimeError) as exc:
return self.send_json(400, {"error": str(exc)})
db.execute("INSERT INTO ai_remote_provider_configs(organization_id,provider,model,enabled,nous_base_url,firecrawl_base_url,credentials_ciphertext,credentials_fingerprint) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(organization_id) DO UPDATE SET provider=excluded.provider,model=excluded.model,enabled=excluded.enabled,nous_base_url=excluded.nous_base_url,firecrawl_base_url=excluded.firecrawl_base_url,credentials_ciphertext=excluded.credentials_ciphertext,credentials_fingerprint=excluded.credentials_fingerprint,updated_at=CURRENT_TIMESTAMP", (org, config["provider"], config["model"], int(config["enabled"]), config["nous_base_url"], config["firecrawl_base_url"], ciphertext, fingerprint))
self.audit(db, user, "ai.remote_provider.updated", config["provider"]); db.commit()
row = db.execute("SELECT * FROM ai_remote_provider_configs WHERE organization_id=?", (org,)).fetchone()
return self.send_json(200, dict(remote_provider_status(row), organization_id=org))
def ai_provider_config(self, db, user, payload=None): def ai_provider_config(self, db, user, payload=None):
remote = db.execute("SELECT * FROM ai_remote_provider_configs WHERE organization_id=?", (user["organization_id"],)).fetchone()
if remote or (payload and str(payload.get("provider", "")).lower() in {"nous_portal", "nous_portal_web_research"}):
return self.remote_ai_provider_config(db, user, payload)
org = user["organization_id"] org = user["organization_id"]
row = db.execute("SELECT * FROM ai_provider_configs WHERE organization_id=?", (org,)).fetchone() row = db.execute("SELECT * FROM ai_provider_configs WHERE organization_id=?", (org,)).fetchone()
if payload is not None: if payload is not None:
@@ -613,6 +647,7 @@ class ApiHandler(BaseHTTPRequestHandler):
if path=="/api/v1/suppressions": return self.list_suppressions(db,org,parse_qs(parsed.query)) if path=="/api/v1/suppressions": return self.list_suppressions(db,org,parse_qs(parsed.query))
if path=="/api/v1/ai-runs": return self.list_ai_runs(db,user,parse_qs(parsed.query)) if path=="/api/v1/ai-runs": return self.list_ai_runs(db,user,parse_qs(parsed.query))
if path=="/api/v1/ai/provider-config": return self.ai_provider_config(db,user) if path=="/api/v1/ai/provider-config": return self.ai_provider_config(db,user)
if path=="/api/v1/admin/ai-provider-config": return self.remote_ai_provider_config(db,user)
if path=="/api/v1/outreach/drafts": return self.list_outreach_drafts(db,user,parse_qs(parsed.query)) if path=="/api/v1/outreach/drafts": return self.list_outreach_drafts(db,user,parse_qs(parsed.query))
if path=="/api/v1/outreach/provider-config": return self.provider_config(db,user) if path=="/api/v1/outreach/provider-config": return self.provider_config(db,user)
if path in ("/api/v1/reports/pipeline","/api/v1/reports/outcomes","/api/v1/reports/activity"): return self.report(db,org,path.rsplit('/',1)[1],parse_qs(parsed.query)) if path in ("/api/v1/reports/pipeline","/api/v1/reports/outcomes","/api/v1/reports/activity"): return self.report(db,org,path.rsplit('/',1)[1],parse_qs(parsed.query))
@@ -1029,6 +1064,8 @@ class ApiHandler(BaseHTTPRequestHandler):
if path=="/api/v1/saved-filters": return self.save_filter(payload,db,user) if path=="/api/v1/saved-filters": return self.save_filter(payload,db,user)
if path=="/api/v1/outreach/provider-config": return self.provider_config(db,user,payload) if path=="/api/v1/outreach/provider-config": return self.provider_config(db,user,payload)
if path=="/api/v1/ai/provider-config": return self.ai_provider_config(db,user,payload) if path=="/api/v1/ai/provider-config": return self.ai_provider_config(db,user,payload)
if path=="/api/v1/admin/ai-provider-config": return self.remote_ai_provider_config(db,user,payload)
if path=="/api/v1/admin/ai-provider-config/test": return self.remote_ai_provider_config(db,user,connectivity=True)
if path=="/api/v1/businesses/bulk-review": return self.bulk_review(payload,db,user) if path=="/api/v1/businesses/bulk-review": return self.bulk_review(payload,db,user)
bits_ai=path.split("/") bits_ai=path.split("/")
if len(bits_ai)==7 and bits_ai[:4]==["","api","v1","businesses"] and bits_ai[5]=="ai" and bits_ai[6]=="suggest": return self.suggest_ai(int(bits_ai[4]) if bits_ai[4].isdigit() else -1,payload,db,user) if len(bits_ai)==7 and bits_ai[:4]==["","api","v1","businesses"] and bits_ai[5]=="ai" and bits_ai[6]=="suggest": return self.suggest_ai(int(bits_ai[4]) if bits_ai[4].isdigit() else -1,payload,db,user)
@@ -1440,6 +1477,7 @@ def _job_worker(server):
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"):
load_config() load_config()
configure_ai_research_db(db_path)
server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();server.job_stop=threading.Event();server.job_wakeup=threading.Event();server.job_thread=threading.Thread(target=_job_worker,args=(server,),daemon=True);server.job_thread.start() server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();server.job_stop=threading.Event();server.job_wakeup=threading.Event();server.job_thread=threading.Thread(target=_job_worker,args=(server,),daemon=True);server.job_thread.start()
original_close=server.server_close original_close=server.server_close
def close(): def close():
+129
View File
@@ -0,0 +1,129 @@
"""Encrypted, tenant-scoped AI provider configuration storage.
The key is generated under the private data volume and is never stored in SQLite.
The small authenticated stream construction here uses HMAC-SHA256 for the
keystream and integrity tag; ciphertext is prefixed with ``pc1`` and never
returned by the API.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import secrets
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import Request, urlopen
MAX_SECRET = 4096
MAX_MODEL = 160
ALLOWED_PROVIDERS = {"nous_portal", "nous_portal_web_research"}
DEFAULT_NOUS_URL = "https://inference-api.nousresearch.com/v1"
DEFAULT_FIRECRAWL_URL = "https://api.firecrawl.dev/v1"
def key_path() -> Path:
return Path(os.environ.get("PROVIDER_CONFIG_KEY_FILE", "/data/provider-config.key")).expanduser()
def _key() -> bytes:
path = key_path()
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
key = path.read_bytes()
if len(key) != 32:
raise RuntimeError("invalid_provider_config_key")
return key
key = secrets.token_bytes(32)
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
fd = os.open(path, flags, 0o600)
try:
os.write(fd, key)
finally:
os.close(fd)
os.chmod(path, 0o600)
return key
def _stream(key: bytes, nonce: bytes, size: int) -> bytes:
return b"".join(hmac.new(key, nonce + i.to_bytes(8, "big"), hashlib.sha256).digest() for i in range((size + 31) // 32))[:size]
def encrypt(value: str) -> str:
raw = value.encode("utf-8")
nonce = secrets.token_bytes(16)
cipher = bytes(a ^ b for a, b in zip(raw, _stream(_key(), nonce, len(raw))))
tag = hmac.new(_key(), nonce + cipher, hashlib.sha256).digest()
return "pc1:" + base64.urlsafe_b64encode(nonce + tag + cipher).decode("ascii")
def decrypt(value: str) -> str:
if not isinstance(value, str) or not value.startswith("pc1:"):
raise ValueError("invalid_ciphertext")
raw = base64.urlsafe_b64decode(value[4:].encode("ascii"))
nonce, tag, cipher = raw[:16], raw[16:48], raw[48:]
key = _key()
if not hmac.compare_digest(tag, hmac.new(key, nonce + cipher, hashlib.sha256).digest()):
raise ValueError("invalid_ciphertext")
return bytes(a ^ b for a, b in zip(cipher, _stream(key, nonce, len(cipher)))).decode("utf-8")
def validate_payload(payload: dict) -> dict:
if not isinstance(payload, dict):
raise ValueError("invalid_config")
provider = str(payload.get("provider", "nous_portal")).strip().lower()
if provider not in ALLOWED_PROVIDERS:
raise ValueError("invalid_provider")
model = str(payload.get("model", "Hermes-4-405B")).strip()
if not model or len(model) > MAX_MODEL:
raise ValueError("invalid_model")
enabled = payload.get("enabled", True)
if not isinstance(enabled, bool):
raise ValueError("invalid_enabled")
urls = {"nous_base_url": DEFAULT_NOUS_URL, "firecrawl_base_url": DEFAULT_FIRECRAWL_URL}
for field, default in urls.items():
value = str(payload.get(field, default)).strip().rstrip("/")
parsed = urlparse(value)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment or parsed.query:
raise ValueError("unsafe_provider_url")
urls[field] = value
credentials = payload.get("credentials", {})
if not isinstance(credentials, dict):
raise ValueError("invalid_credentials")
result = {"provider": provider, "model": model, "enabled": enabled, **urls, "credentials": {}}
for name in ("nous_api_key", "firecrawl_api_key"):
if name in credentials:
value = credentials[name]
if not isinstance(value, str) or not value or len(value) > MAX_SECRET:
raise ValueError("invalid_secret")
result["credentials"][name] = value
return result
def safe_status(row, *, env_bootstrap=False) -> dict:
if not row:
return {"provider": "", "status": "not_configured", "configured": False, "enabled": False, "network_enabled": False, "outbound_calls": False, "source": "env_bootstrap" if env_bootstrap else "none"}
return {"provider": row["provider"], "model": row["model"], "enabled": bool(row["enabled"]), "configured": bool(row["credentials_ciphertext"]), "status": "ready" if row["enabled"] and row["credentials_ciphertext"] else "disabled", "network_enabled": bool(row["enabled"] and row["credentials_ciphertext"]), "outbound_calls": bool(row["enabled"] and row["credentials_ciphertext"]), "source": "database", "nous_host": urlparse(row["nous_base_url"]).hostname, "firecrawl_host": urlparse(row["firecrawl_base_url"]).hostname, "updated_at": row["updated_at"]}
def test_connectivity(row) -> dict:
"""Make only bounded GET requests to fixed configured HTTPS hosts."""
if not row or not row["enabled"] or not row["credentials_ciphertext"]:
return {"status": "not_configured", "network_calls": 0, "outbound_calls": False}
credentials = json.loads(decrypt(row["credentials_ciphertext"]))
checks = []
for label, url, key_name in (("nous", row["nous_base_url"] + "/models", "nous_api_key"), ("firecrawl", row["firecrawl_base_url"], "firecrawl_api_key")):
key = credentials.get(key_name)
if not isinstance(key, str) or not key:
checks.append({"provider": label, "ok": False})
continue
request = Request(url, headers={"Accept": "application/json", "Authorization": "Bearer " + key}, method="GET")
try:
with urlopen(request, timeout=3) as response:
response.read(8193)
checks.append({"provider": label, "ok": 200 <= response.status < 500})
except Exception:
checks.append({"provider": label, "ok": False})
return {"status": "ready" if all(x["ok"] for x in checks) else "unavailable", "network_calls": len(checks), "outbound_calls": False, "checks": checks}
+13
View File
@@ -292,6 +292,19 @@ CREATE TABLE IF NOT EXISTS ai_provider_configs (
UNIQUE(organization_id) UNIQUE(organization_id)
); );
-- Tenant-scoped remote AI credentials. Ciphertext is encrypted with the private
-- key in /data; this table never stores plaintext credentials.
CREATE TABLE IF NOT EXISTS ai_remote_provider_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
provider TEXT NOT NULL, model TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 0,
nous_base_url TEXT NOT NULL, firecrawl_base_url TEXT NOT NULL,
credentials_ciphertext TEXT NOT NULL DEFAULT '', credentials_fingerprint TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(organization_id)
);
CREATE INDEX IF NOT EXISTS idx_ai_remote_provider_org ON ai_remote_provider_configs(organization_id);
-- Phase 14 outreach preparation. Provider configuration is metadata plus a -- Phase 14 outreach preparation. Provider configuration is metadata plus a
-- one-way secret fingerprint; outbound transport is intentionally disabled. -- one-way secret fingerprint; outbound transport is intentionally disabled.
CREATE TABLE IF NOT EXISTS outreach_provider_configs ( CREATE TABLE IF NOT EXISTS outreach_provider_configs (
+43
View File
@@ -0,0 +1,43 @@
import json, os, sqlite3, stat, threading, unittest
from http.client import HTTPConnection
from tempfile import TemporaryDirectory
from unittest.mock import patch
from app.main import create_server, hash_password
class ProviderConfigTests(unittest.TestCase):
def setUp(self):
self.tmp=TemporaryDirectory(); self.old_env={k:os.environ.get(k) for k in ('BOOTSTRAP_ADMIN_EMAIL','BOOTSTRAP_ADMIN_PASSWORD','PROVIDER_CONFIG_KEY_FILE','AI_RESEARCH_PROVIDER')}; os.environ['BOOTSTRAP_ADMIN_EMAIL']='pc-owner@test'; os.environ['BOOTSTRAP_ADMIN_PASSWORD']='pw'; os.environ['PROVIDER_CONFIG_KEY_FILE']=self.tmp.name+'/provider.key'
self.db=self.tmp.name+'/db.sqlite'; self.server=create_server('127.0.0.1',0,self.db); self.thread=threading.Thread(target=self.server.serve_forever,daemon=True); self.thread.start(); self.conn=HTTPConnection('127.0.0.1',self.server.server_port); self.cookie=None; self.req('POST','/api/v1/auth/login',{'email':'pc-owner@test','password':'pw'})
def tearDown(self):
self.server.shutdown(); self.server.server_close(); self.thread.join(2)
for k,v in self.old_env.items():
if v is None: os.environ.pop(k,None)
else: os.environ[k]=v
self.tmp.cleanup()
def req(self,m,p,b=None):
body=json.dumps(b).encode() if b is not None else None; h={'Content-Type':'application/json'}
if self.cookie:h['Cookie']=self.cookie
self.conn.request(m,p,body,h); r=self.conn.getresponse(); c=r.getheader('Set-Cookie');
if c:self.cookie=c.split(';',1)[0]
return r.status,json.loads(r.read() or b'{}')
def test_admin_can_store_write_only_encrypted_config_and_read_status(self):
payload={'provider':'nous_portal','model':'Hermes-test','enabled':True,'credentials':{'nous_api_key':'nous-secret','firecrawl_api_key':'fire-secret'}}
status,out=self.req('POST','/api/v1/admin/ai-provider-config',payload); self.assertEqual(status,200); self.assertNotIn('secret',json.dumps(out)); self.assertEqual(out['source'],'database'); self.assertTrue(out['configured'])
with open(self.db,'rb') as handle: raw=handle.read()
self.assertNotIn(b'nous-secret',raw); self.assertNotIn(b'fire-secret',raw)
mode=stat.S_IMODE(os.stat(os.environ['PROVIDER_CONFIG_KEY_FILE']).st_mode); self.assertEqual(mode,0o600)
status,out=self.req('GET','/api/v1/admin/ai-provider-config'); self.assertNotIn('credentials',out); self.assertEqual(out['status'],'ready')
def test_viewer_cannot_mutate_but_can_read_safe_status(self):
ph,s=hash_password('viewer'); db=sqlite3.connect(self.db); db.execute("INSERT INTO users(organization_id,email,password_hash,password_salt,role) VALUES(?,?,?,?,?)",('demo-tenant','pc-viewer@test',ph,s,'viewer')); db.commit(); db.close(); self.cookie=None; self.req('POST','/api/v1/auth/login',{'email':'pc-viewer@test','password':'viewer'})
self.assertEqual(self.req('GET','/api/v1/admin/ai-provider-config')[0],200); self.assertEqual(self.req('POST','/api/v1/admin/ai-provider-config',{'provider':'nous_portal'})[0],403)
def test_invalid_config_and_connectivity_never_sends_outreach(self):
self.assertEqual(self.req('POST','/api/v1/admin/ai-provider-config',{'provider':'nous_portal','enabled':True,'credentials':{'nous_api_key':'x'}})[0],400)
self.req('POST','/api/v1/admin/ai-provider-config',{'provider':'nous_portal','enabled':True,'credentials':{'nous_api_key':'x','firecrawl_api_key':'y'}})
with patch('app.provider_config.urlopen') as opened:
status,out=self.req('POST','/api/v1/admin/ai-provider-config/test',{}); self.assertEqual(status,200); self.assertFalse(out['outbound_calls']); self.assertEqual(out['network_calls'],2); self.assertEqual(opened.call_count,2)
for call in opened.call_args_list: self.assertEqual(call.args[0].method,'GET')
def test_persists_after_server_restart_and_db_config_beats_env(self):
self.req('POST','/api/v1/admin/ai-provider-config',{'provider':'nous_portal','enabled':True,'credentials':{'nous_api_key':'x','firecrawl_api_key':'y'}}); self.server.shutdown(); self.server.server_close(); self.thread.join(2)
os.environ['AI_RESEARCH_PROVIDER']='untrusted'; self.server=create_server('127.0.0.1',0,self.db); self.thread=threading.Thread(target=self.server.serve_forever,daemon=True); self.thread.start(); self.conn=HTTPConnection('127.0.0.1',self.server.server_port); self.cookie=None; self.req('POST','/api/v1/auth/login',{'email':'pc-owner@test','password':'pw'}); status,out=self.req('GET','/api/v1/admin/ai-provider-config'); self.assertEqual(status,200); self.assertEqual(out['provider'],'nous_portal')
if __name__=='__main__': unittest.main()
+17 -2
View File
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"schema": 1, "schema": 1,
"version": "phase-17", "version": "phase-18",
"entrypoints": [ "entrypoints": [
"config.js", "config.js",
"app.js", "app.js",
@@ -14,9 +14,9 @@
], ],
"integrity": { "integrity": {
"config.js": "sha256-f9c7b4db3eab4cf54146bd25891b5103b09ae75da93c57b548cf57ae93e4a3f6", "config.js": "sha256-f9c7b4db3eab4cf54146bd25891b5103b09ae75da93c57b548cf57ae93e4a3f6",
"app.js": "sha256-cf0d8b00773877c09f03707030e3e8113b3f34ad389054e0a97ecb1dbddd139f", "app.js": "sha256-34521da899dacc84d58200258735f09515d4b527028e1ba8c53ce6b66ad77c8a",
"styles.css": "sha256-56ca598c8e8cbbcb91d81013c6a498f75193de79e33dd2d8d117df836fc43d57", "styles.css": "sha256-ca618b1a44a1f310029ab9d6986a4120e9147cd61784c3e7a0ff826cd2d42696",
"index.html": "sha256-f3c9544c07c08c5248abafaf8075551ee641704f9cb3d516449188f74bf86644", "index.html": "sha256-d2f21cd3babbd276c90742e3aba4c815ce7833d151ec4660ae5cab6078011eee",
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81", "health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
"error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf", "error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf",
"healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22" "healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22"
+5 -3
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>ProspectOS · Pipeline intelligence</title> <title>ProspectOS · Pipeline intelligence</title>
<meta name="description" content="Prospect discovery and review dashboard"> <meta name="description" content="Prospect discovery and review dashboard">
<link rel="stylesheet" href="styles.css?v=phase-17"> <link rel="stylesheet" href="styles.css?v=phase-18">
</head> </head>
<body> <body>
<section class="login-screen" id="loginScreen" aria-labelledby="loginTitle"> <section class="login-screen" id="loginScreen" aria-labelledby="loginTitle">
@@ -36,6 +36,7 @@
<a class="nav-item" href="#crmReports" data-nav="reports"><span>▤</span> Reports</a> <a class="nav-item" href="#crmReports" data-nav="reports"><span>▤</span> Reports</a>
<a class="nav-item" href="#suppressionCenter" data-nav="suppression"><span>⊘</span> Suppressions</a> <a class="nav-item" href="#suppressionCenter" data-nav="suppression"><span>⊘</span> Suppressions</a>
<a class="nav-item" href="#outreachSettings" data-nav="outreach-settings"><span>⚙</span> Outreach policy</a> <a class="nav-item" href="#outreachSettings" data-nav="outreach-settings"><span>⚙</span> Outreach policy</a>
<a class="nav-item" href="#aiProviderSettings" data-nav="ai-provider-settings"><span>✧</span> AI providers</a>
<a class="nav-item" href="#scoreRules" data-nav="score-rules"><span>◈</span> Score rules</a> <a class="nav-item" href="#scoreRules" data-nav="score-rules"><span>◈</span> Score rules</a>
</nav> </nav>
<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>
@@ -120,6 +121,7 @@
<div class="crm-two-col"><article class="panel"><div class="panel-heading"><div><p class="eyebrow">ADD RULE</p><h3>Add suppression</h3></div><span class="small-label">Explicit confirmation required</span></div><form id="suppressionForm" class="crm-form"><label>Kind<select name="kind"><option value="email">Email</option><option value="domain">Domain</option><option value="phone">Phone</option></select></label><label>Value<input name="value" required placeholder="person@example.com"></label><label>Reason <span class="optional">optional</span><input name="reason" placeholder="Customer request / policy"></label><p id="suppressionMessage" class="form-message" role="status"></p><button class="button danger" type="submit">Add suppression</button></form></article><article class="panel"><div class="panel-heading"><div><p class="eyebrow">REGISTER</p><h3>Current suppressions</h3></div><div class="suppression-bulk-actions"><label class="checkbox-label"><input id="selectAllSuppressions" type="checkbox"> Select all</label><button class="button ghost compact" id="bulkReviewSuppressionsBtn" type="button" disabled>Review selected</button></div></div><div id="suppressionState" class="detail-loading">Sign in to load suppressions.</div></article></div> <div class="crm-two-col"><article class="panel"><div class="panel-heading"><div><p class="eyebrow">ADD RULE</p><h3>Add suppression</h3></div><span class="small-label">Explicit confirmation required</span></div><form id="suppressionForm" class="crm-form"><label>Kind<select name="kind"><option value="email">Email</option><option value="domain">Domain</option><option value="phone">Phone</option></select></label><label>Value<input name="value" required placeholder="person@example.com"></label><label>Reason <span class="optional">optional</span><input name="reason" placeholder="Customer request / policy"></label><p id="suppressionMessage" class="form-message" role="status"></p><button class="button danger" type="submit">Add suppression</button></form></article><article class="panel"><div class="panel-heading"><div><p class="eyebrow">REGISTER</p><h3>Current suppressions</h3></div><div class="suppression-bulk-actions"><label class="checkbox-label"><input id="selectAllSuppressions" type="checkbox"> Select all</label><button class="button ghost compact" id="bulkReviewSuppressionsBtn" type="button" disabled>Review selected</button></div></div><div id="suppressionState" class="detail-loading">Sign in to load suppressions.</div></article></div>
</section> </section>
<section class="crm-section outreach-settings-section" id="outreachSettings" aria-labelledby="outreachSettingsTitle" data-smoke="outreach-provider-policy"><div class="crm-header panel"><div><p class="eyebrow">GOVERNANCE</p><h2 id="outreachSettingsTitle">Outreach provider policy</h2><p class="muted">View approved provider status without exposing credentials or secrets.</p></div><button class="button ghost" id="outreachPolicyRefreshBtn" type="button">↻ Refresh policy</button></div><div class="outreach-settings-safety" role="note"><strong>Sending is disabled by default.</strong> This panel is status-only. Provider configuration never creates a send trigger, and no credentials are displayed.</div><div id="providerPolicyPanel" class="provider-policy-panel" aria-live="polite"><div class="detail-loading">Sign in to load provider policy.</div></div></section> <section class="crm-section outreach-settings-section" id="outreachSettings" aria-labelledby="outreachSettingsTitle" data-smoke="outreach-provider-policy"><div class="crm-header panel"><div><p class="eyebrow">GOVERNANCE</p><h2 id="outreachSettingsTitle">Outreach provider policy</h2><p class="muted">View approved provider status without exposing credentials or secrets.</p></div><button class="button ghost" id="outreachPolicyRefreshBtn" type="button">↻ Refresh policy</button></div><div class="outreach-settings-safety" role="note"><strong>Sending is disabled by default.</strong> This panel is status-only. Provider configuration never creates a send trigger, and no credentials are displayed.</div><div id="providerPolicyPanel" class="provider-policy-panel" aria-live="polite"><div class="detail-loading">Sign in to load provider policy.</div></div></section>
<section class="crm-section ai-provider-settings-section" id="aiProviderSettings" aria-labelledby="aiProviderSettingsTitle" data-smoke="ai-provider-settings"><div class="crm-header panel"><div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2 id="aiProviderSettingsTitle">AI Provider Settings</h2><p class="muted">Configure the approved Nous Portal and Firecrawl services used by workspace AI research.</p></div><button class="button ghost" id="aiProviderRefreshBtn" type="button">↻ Refresh status</button></div><div class="ai-provider-safety" role="note"><strong>Admin-only and write-only credentials.</strong> Keys are sent only over the authenticated API, are never displayed or stored in this browser, and updating settings does not start discovery or AI work.</div><div id="aiProviderAdminState" class="provider-state" role="status" hidden></div><div class="ai-provider-grid"><article class="panel ai-provider-form-panel"><div class="panel-heading"><div><p class="eyebrow">PROVIDER CONFIGURATION</p><h3>Service connection</h3></div><span class="small-label">Approved providers only</span></div><form id="aiProviderForm"><div class="form-grid"><label>AI provider<select id="aiProvider" name="provider"><option value="nous_portal">Nous Portal</option></select></label><label>Nous Portal model<input id="nousModel" name="nous_model" required maxlength="160" placeholder="Hermes 4 405B" autocomplete="off"></label><label>Nous Portal base URL<input id="nousBaseUrl" name="nous_base_url" type="url" required placeholder="https://inference-api.nousresearch.com/v1" autocomplete="off"></label><label>Nous Portal API key <span class="optional">write-only · leave blank to keep</span><input id="nousApiKey" name="nous_api_key" type="password" maxlength="512" placeholder="Enter a new key to rotate" autocomplete="new-password"></label><label>Firecrawl base URL<input id="firecrawlBaseUrl" name="firecrawl_base_url" type="url" required placeholder="https://api.firecrawl.dev/v1" autocomplete="off"></label><label>Firecrawl API key <span class="optional">write-only · leave blank to keep</span><input id="firecrawlApiKey" name="firecrawl_api_key" type="password" maxlength="512" placeholder="Enter a new key to rotate" autocomplete="new-password"></label></div><div class="form-footer"><p id="aiProviderMessage" class="form-message" role="status" aria-live="polite"></p><button class="button primary" id="saveAiProviderBtn" type="submit">Save provider settings</button></div></form></article><aside class="panel ai-provider-status-panel"><div class="panel-heading"><div><p class="eyebrow">SAFE STATUS</p><h3>Connection status</h3></div><button class="button ghost compact" id="testAiProviderBtn" type="button">Test connection</button></div><div id="aiProviderStatus" aria-live="polite"><div class="detail-loading">Sign in to load provider status.</div></div></aside></div></section>
<footer><span>ProspectOS</span><span>Evidence-led prospecting · <a href="#explorer">Review queue</a></span></footer> <footer><span>ProspectOS</span><span>Evidence-led prospecting · <a href="#explorer">Review queue</a></span></footer>
</div> </div>
</main> </main>
@@ -127,7 +129,7 @@
<div class="merge-dialog" id="mergeDialog" hidden role="dialog" aria-modal="true" aria-labelledby="mergeDialogTitle"> <div class="merge-dialog" id="mergeDialog" hidden role="dialog" aria-modal="true" aria-labelledby="mergeDialogTitle">
<div class="merge-dialog-card"><div class="panel-heading"><div><p class="eyebrow">REVIEW REQUIRED</p><h2 id="mergeDialogTitle">Confirm merge</h2></div><button class="icon-button" id="cancelMergeBtn" type="button" aria-label="Close merge confirmation">×</button></div><p id="mergeDialogCopy"></p><div class="merge-warning"><strong>This action is reversible.</strong> The merge will be recorded in history and can be reversed later.</div><p id="mergeDialogMessage" class="form-message" role="alert" aria-live="polite"></p><div class="merge-dialog-actions"><button class="button ghost" id="cancelMergeBtnSecondary" type="button">Cancel</button><button class="button primary" id="confirmMergeBtn" type="button">Confirm merge</button></div></div> <div class="merge-dialog-card"><div class="panel-heading"><div><p class="eyebrow">REVIEW REQUIRED</p><h2 id="mergeDialogTitle">Confirm merge</h2></div><button class="icon-button" id="cancelMergeBtn" type="button" aria-label="Close merge confirmation">×</button></div><p id="mergeDialogCopy"></p><div class="merge-warning"><strong>This action is reversible.</strong> The merge will be recorded in history and can be reversed later.</div><p id="mergeDialogMessage" class="form-message" role="alert" aria-live="polite"></p><div class="merge-dialog-actions"><button class="button ghost" id="cancelMergeBtnSecondary" type="button">Cancel</button><button class="button primary" id="confirmMergeBtn" type="button">Confirm merge</button></div></div>
</div> </div>
<script src="config.js?v=phase-17"></script> <script src="config.js?v=phase-18"></script>
<script src="app.js?v=phase-17"></script> <script src="app.js?v=phase-18"></script>
</body> </body>
</html> </html>
+6 -4
View File
@@ -59,12 +59,12 @@ const expectedIds = [
'deleteSavedFilterBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'nextPageBtn', 'jobs', 'jobsList', 'jobDetailPanel', 'deleteSavedFilterBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'nextPageBtn', 'jobs', 'jobsList', 'jobDetailPanel',
'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'discoveryWorkspace', 'directDiscoveryForm', 'directDiscoveryRunsState', 'directDiscoveryResultState', 'crmPipeline', 'pipelineBoard', 'crmActivity', 'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'discoveryWorkspace', 'directDiscoveryForm', 'directDiscoveryRunsState', 'directDiscoveryResultState', 'crmPipeline', 'pipelineBoard', 'crmActivity',
'interactionState', 'interactionForm', 'crmReports', 'pipelineReport', 'outcomesReport', 'activityReport', 'interactionState', 'interactionForm', 'crmReports', 'pipelineReport', 'outcomesReport', 'activityReport',
'suppressionCenter', 'suppressionForm', 'suppressionState', 'outreachSettings', 'providerPolicyPanel', 'suppressionCenter', 'suppressionForm', 'suppressionState', 'outreachSettings', 'providerPolicyPanel', 'aiProviderSettings', 'aiProviderForm', 'aiProviderStatus', 'aiProviderRefreshBtn', 'testAiProviderBtn', 'saveAiProviderBtn',
'scoreRules', 'scoreRulesPanel', 'scoreDistributionPanel', 'mergeDialog', 'confirmMergeBtn' 'scoreRules', 'scoreRulesPanel', 'scoreDistributionPanel', 'mergeDialog', 'confirmMergeBtn'
]; ];
const htmlIds = new Set([...html.matchAll(/\bid=["']([^"']+)["']/g)].map(m => m[1])); const htmlIds = new Set([...html.matchAll(/\bid=["']([^"']+)["']/g)].map(m => m[1]));
check('dom.critical-ids', 'critical operator DOM IDs are present', all(expectedIds, id => htmlIds.has(id)), listMissing(expectedIds, id => htmlIds.has(id)).join(', ')); check('dom.critical-ids', 'critical operator DOM IDs are present', all(expectedIds, id => htmlIds.has(id)), listMissing(expectedIds, id => htmlIds.has(id)).join(', '));
const expectedMarkers = ['saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports', 'suppression-center', 'outreach-provider-policy', 'score-rules', 'score-distribution']; const expectedMarkers = ['saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports', 'suppression-center', 'outreach-provider-policy', 'ai-provider-settings', 'score-rules', 'score-distribution'];
const smokeMarkers = new Set([...html.matchAll(/data-smoke=["']([^"']+)["']/g)].map(m => m[1])); const smokeMarkers = new Set([...html.matchAll(/data-smoke=["']([^"']+)["']/g)].map(m => m[1]));
check('dom.smoke-markers', 'critical sections expose stable smoke markers', all(expectedMarkers, marker => smokeMarkers.has(marker)), listMissing(expectedMarkers, marker => smokeMarkers.has(marker)).join(', ')); check('dom.smoke-markers', 'critical sections expose stable smoke markers', all(expectedMarkers, marker => smokeMarkers.has(marker)), listMissing(expectedMarkers, marker => smokeMarkers.has(marker)).join(', '));
const dynamicMarkers = ['score-breakdown', 'deduplication']; const dynamicMarkers = ['score-breakdown', 'deduplication'];
@@ -75,7 +75,7 @@ const routeContracts = [
'/api/v1/review-queue', '/api/v1/saved-filters', '/api/v1/businesses/bulk-review', '/api/v1/jobs', '/api/v1/sources', '/api/v1/review-queue', '/api/v1/saved-filters', '/api/v1/businesses/bulk-review', '/api/v1/jobs', '/api/v1/sources',
'/api/v1/source-records', '/api/v1/discovery', '/api/v1/discovery-runs', '/api/v1/discovery-queries', '/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules', '/api/v1/source-records', '/api/v1/discovery', '/api/v1/discovery-runs', '/api/v1/discovery-queries', '/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules',
'/api/v1/pipeline-entries', '/api/v1/interactions', '/api/v1/reports/pipeline', '/api/v1/reports/outcomes', '/api/v1/reports/activity', '/api/v1/pipeline-entries', '/api/v1/interactions', '/api/v1/reports/pipeline', '/api/v1/reports/outcomes', '/api/v1/reports/activity',
'/api/v1/suppressions', '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config', '/api/v1/suppressions', '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config', '/api/v1/ai/provider-config', '/api/v1/admin/ai-provider-config/test',
'/matches', '/domains/check', '/domain-candidates', '/websites/scan', '/contacts/extract', '/score/recalculate', '/pipeline', '/verify' '/matches', '/domains/check', '/domain-candidates', '/websites/scan', '/contacts/extract', '/score/recalculate', '/pipeline', '/verify'
]; ];
check('routes.contracts', 'all critical API route contracts are referenced by the client', all(routeContracts, route => js.includes(route)), listMissing(routeContracts, route => js.includes(route)).join(', ')); check('routes.contracts', 'all critical API route contracts are referenced by the client', all(routeContracts, route => js.includes(route)), listMissing(routeContracts, route => js.includes(route)).join(', '));
@@ -105,7 +105,7 @@ if (manifestAssets) {
const linkedAssets = [...new Set([...html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)].map(m => m[1]).filter(asset => !/^(?:https?:|data:|#)/i.test(asset)).map(asset => asset.replace(/^\.\//, '')) )]; const linkedAssets = [...new Set([...html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)].map(m => m[1]).filter(asset => !/^(?:https?:|data:|#)/i.test(asset)).map(asset => asset.replace(/^\.\//, '')) )];
check('assets.linked-local', 'all local HTML assets exist and are non-empty', all(linkedAssets, asset => files[asset]?.length > 0), listMissing(linkedAssets, asset => files[asset]?.length > 0).join(', ')); check('assets.linked-local', 'all local HTML assets exist and are non-empty', all(linkedAssets, asset => files[asset]?.length > 0), listMissing(linkedAssets, asset => files[asset]?.length > 0).join(', '));
check('css.responsive', 'responsive CSS covers mobile layouts and critical grids', /@media\s*\(\s*max-width\s*:\s*700px\s*\)/.test(css) && all(['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.pipeline-board'], selector => css.includes(selector))); check('css.responsive', 'responsive CSS covers mobile layouts and critical grids', /@media\s*\(\s*max-width\s*:\s*700px\s*\)/.test(css) && all(['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.pipeline-board', '.ai-provider-grid'], selector => css.includes(selector)));
check('css.layout-contracts', 'critical desktop layout selectors are defined', all(['.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row'], selector => css.includes(selector))); check('css.layout-contracts', 'critical desktop layout selectors are defined', all(['.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row'], selector => css.includes(selector)));
const combined = `${html}\n${js}\n${css}\n${config}\n${text('README.md')}`; const combined = `${html}\n${js}\n${css}\n${config}\n${text('README.md')}`;
@@ -127,6 +127,8 @@ const secretPatterns = [
]; ];
const secretHits = secretPatterns.flatMap(pattern => [...combined.matchAll(new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`))].map(m => m[0].slice(0, 80))); const secretHits = secretPatterns.flatMap(pattern => [...combined.matchAll(new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`))].map(m => m[0].slice(0, 80)));
check('safety.no-hardcoded-secrets', 'frontend source has no obvious hardcoded secrets', secretHits.length === 0, secretHits.join(' | ')); check('safety.no-hardcoded-secrets', 'frontend source has no obvious hardcoded secrets', secretHits.length === 0, secretHits.join(' | '));
check('ai-provider.write-only', 'AI provider keys are write-only and cleared after save', all(['type="password"', 'autocomplete="new-password"', "input.value=''", 'input[type="password"]'], token => combined.includes(token)) && !/localStorage[^\n]*(?:nous|firecrawl|api[_-]?key)/i.test(combined));
check('ai-provider.admin-states', 'AI provider settings expose safe status states and admin-only messaging', all(['/api/v1/ai/provider-config', '/api/v1/admin/ai-provider-config/test', 'Not configured', 'Invalid configuration', 'Administrator access required'], token => combined.includes(token)));
async function httpChecks(url) { async function httpChecks(url) {
for (const asset of [...new Set([...(manifestAssets || []), ...linkedAssets])].sort()) { for (const asset of [...new Set([...(manifestAssets || []), ...linkedAssets])].sort()) {
+10 -6
View File
@@ -44,25 +44,25 @@ check('dom.operator-markers', 'operator-critical DOM markers are present', has(h
'id="loginScreen"', 'id="dashboardShell"', 'id="explorer"', 'id="detailPanel"', 'id="loginScreen"', 'id="dashboardShell"', 'id="explorer"', 'id="detailPanel"',
'id="reviewQueueCount"', 'id="jobsList"', 'id="sourcesList"', 'id="pipelineBoard"', 'id="reviewQueueCount"', 'id="jobsList"', 'id="sourcesList"', 'id="pipelineBoard"',
'id="interactionState"', 'id="pipelineReport"', 'id="suppressionState"', 'id="interactionState"', 'id="pipelineReport"', 'id="suppressionState"',
'id="providerPolicyPanel"', 'id="scoreRulesPanel"', 'id="scoreDistributionPanel"' 'id="providerPolicyPanel"', 'id="aiProviderSettings"', 'id="aiProviderForm"', 'id="aiProviderStatus"', 'id="scoreRulesPanel"', 'id="scoreDistributionPanel"'
])); ]));
check('dom.safety-markers', 'safety and approval sections have stable smoke markers', [ check('dom.safety-markers', 'safety and approval sections have stable smoke markers', [
'saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports', 'saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports',
'suppression-center', 'outreach-provider-policy', 'score-rules', 'score-distribution' 'suppression-center', 'outreach-provider-policy', 'ai-provider-settings', 'score-rules', 'score-distribution'
].every(marker => markers.has(marker))); ].every(marker => markers.has(marker)));
check('dom.required-controls', 'operator controls have stable IDs', [ check('dom.required-controls', 'operator controls have stable IDs', [
'loginEmail', 'loginPassword', 'logoutBtn', 'nextPageBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'loginEmail', 'loginPassword', 'logoutBtn', 'nextPageBtn', 'bulkVerifyBtn', 'bulkRejectBtn',
'savedFilterForm', 'pipelineViewToggle', 'interactionForm', 'suppressionForm', 'outreachPolicyRefreshBtn' 'savedFilterForm', 'pipelineViewToggle', 'interactionForm', 'suppressionForm', 'outreachPolicyRefreshBtn', 'aiProviderRefreshBtn', 'testAiProviderBtn', 'saveAiProviderBtn'
].every(id => htmlIds.has(id)) && has(js, [ ].every(id => htmlIds.has(id)) && has(js, [
'scanWebsiteBtn', 'extractContactsBtn', 'recalculateScoreBtn', 'generateAiSuggestionBtn', 'createOutreachDraftBtn' 'scanWebsiteBtn', 'extractContactsBtn', 'recalculateScoreBtn', 'generateAiSuggestionBtn', 'createOutreachDraftBtn'
])); ]));
check('css.responsive', 'responsive CSS is present for mobile operator layouts', check('css.responsive', 'responsive CSS is present for mobile operator layouts',
/@media\s*\(max-width\s*:\s*700px\)/.test(css) && /@media\s*\(max-width\s*:\s*700px\)/.test(css) &&
has(css, ['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel'])); has(css, ['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.ai-provider-grid']));
check('css.layout-contracts', 'critical layout selectors are defined', has(css, [ check('css.layout-contracts', 'critical layout selectors are defined', has(css, [
'.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.pipeline-board', '.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.pipeline-board',
'.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row' '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row', '.ai-provider-status-row'
])); ]));
check('safety.no-send-copy', 'persistent no-send and approval safety copy is present', has(`${html}\n${js}`, [ check('safety.no-send-copy', 'persistent no-send and approval safety copy is present', has(`${html}\n${js}`, [
@@ -79,6 +79,10 @@ check('safety.no-secrets', 'frontend source has no obvious hardcoded secrets',
)); ));
check('safety.approval-gated', 'approval is explicitly human-confirmed and non-delivering', check('safety.approval-gated', 'approval is explicitly human-confirmed and non-delivering',
has(js, ['window.confirm', 'human_approval:true', 'send:false', 'Approval does not send a message.'])); has(js, ['window.confirm', 'human_approval:true', 'send:false', 'Approval does not send a message.']));
check('ai-provider.write-only', 'AI provider credentials are write-only and never browser-persisted',
has(`${html}\n${js}`, ['type="password"', 'autocomplete="new-password"', "input.value=''", 'input[type="password"]']) && !/localStorage[^\n]*(?:nous|firecrawl|api[_-]?key)/i.test(`${html}\n${js}`));
check('ai-provider.states', 'AI provider status states and admin messaging are represented',
has(`${html}\n${js}`, ['/api/v1/ai/provider-config', '/api/v1/admin/ai-provider-config/test', 'Not configured', 'Invalid configuration', 'Administrator access required']));
const routes = [ const routes = [
'/api/v1/auth/me', '/api/v1/auth/login', '/api/v1/auth/logout', '/api/v1/businesses', '/api/v1/auth/me', '/api/v1/auth/login', '/api/v1/auth/logout', '/api/v1/businesses',
@@ -87,7 +91,7 @@ const routes = [
'/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules', '/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules',
'/api/v1/pipeline-entries', '/api/v1/interactions', '/api/v1/reports/pipeline', '/api/v1/pipeline-entries', '/api/v1/interactions', '/api/v1/reports/pipeline',
'/api/v1/reports/outcomes', '/api/v1/reports/activity', '/api/v1/suppressions', '/api/v1/reports/outcomes', '/api/v1/reports/activity', '/api/v1/suppressions',
'/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config' '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config', '/api/v1/ai/provider-config', '/api/v1/admin/ai-provider-config/test'
]; ];
check('routes.contracts', 'operator API route contracts are referenced by the client', check('routes.contracts', 'operator API route contracts are referenced by the client',
routes.every(route => js.includes(route)), routes.filter(route => !js.includes(route)).join(', ')); routes.every(route => js.includes(route)), routes.filter(route => !js.includes(route)).join(', '));
+20 -1
View File
@@ -236,10 +236,29 @@ td:first-child { color: var(--ink-strong); font-weight: 750; }
.website-scan-panel, .domain-intelligence, .contact-extraction-panel { margin-top: 14px; padding: 15px; border: 1px solid var(--line); border-radius: 10px; background: #f8fbfa; }.website-scan-meta, .domain-check-meta { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; margin: 12px 0; color: var(--muted); font-size: 11px; }.website-scan-meta > span { display: flex; align-items: center; gap: 6px; }.website-scan-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }.website-contact-signals { margin-top: 13px; }.website-contact-signals h5 { margin: 0 0 8px; }.signal-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }.signal { display: grid; gap: 2px; padding: 8px; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); background: #fff; font-size: 11px; }.signal b { color: var(--ink); font-size: 10px; }.signal.present b { color: var(--green); }.signal.absent b { color: var(--red); }.website-scan-error, .structured-error { margin: 10px 0; padding: 10px; border: 1px solid #e9b8bd; border-radius: 8px; color: var(--red); background: var(--red-soft); font-size: 12px; }.dns-capabilities { display: grid; grid-template-columns: repeat(5, 1fr); gap: 7px; margin: 0; }.candidate-domain-panel, .merge-history { margin-top: 15px; padding-top: 15px; border-top: 1px solid var(--line); }.candidate-list, .match-list, .extracted-contact-list { display: grid; gap: 9px; margin-top: 10px; }.candidate-card, .match-card, .extracted-contact { padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: #fff; }.candidate-head, .match-card-head, .extracted-contact-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }.match-confidence, .contact-confidence { color: var(--teal); background: var(--teal-soft); }.match-reasons { margin: 8px 0; padding-left: 17px; color: var(--muted); font-size: 12px; }.extracted-contact-facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; margin: 10px 0 0; }.contact-source { grid-column: 1 / -1; }.extracted-contact-facts a { color: var(--teal); }.dedup-panel { margin-top: 14px; padding: 15px; border: 1px solid var(--line); border-radius: 10px; background: #f8fbfa; }.merge-dialog { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 22px; background: rgba(13,27,42,.62); }.merge-dialog-card { width: min(100%, 540px); padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: 0 25px 80px rgba(13,27,42,.3); }.merge-dialog-card h2 { margin: 2px 0; }.merge-warning { margin: 15px 0; padding: 12px; border: 1px solid #ead6a9; border-radius: 9px; color: #77500c; background: var(--amber-soft); font-size: 12px; }.merge-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } .website-scan-panel, .domain-intelligence, .contact-extraction-panel { margin-top: 14px; padding: 15px; border: 1px solid var(--line); border-radius: 10px; background: #f8fbfa; }.website-scan-meta, .domain-check-meta { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; margin: 12px 0; color: var(--muted); font-size: 11px; }.website-scan-meta > span { display: flex; align-items: center; gap: 6px; }.website-scan-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }.website-contact-signals { margin-top: 13px; }.website-contact-signals h5 { margin: 0 0 8px; }.signal-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }.signal { display: grid; gap: 2px; padding: 8px; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); background: #fff; font-size: 11px; }.signal b { color: var(--ink); font-size: 10px; }.signal.present b { color: var(--green); }.signal.absent b { color: var(--red); }.website-scan-error, .structured-error { margin: 10px 0; padding: 10px; border: 1px solid #e9b8bd; border-radius: 8px; color: var(--red); background: var(--red-soft); font-size: 12px; }.dns-capabilities { display: grid; grid-template-columns: repeat(5, 1fr); gap: 7px; margin: 0; }.candidate-domain-panel, .merge-history { margin-top: 15px; padding-top: 15px; border-top: 1px solid var(--line); }.candidate-list, .match-list, .extracted-contact-list { display: grid; gap: 9px; margin-top: 10px; }.candidate-card, .match-card, .extracted-contact { padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: #fff; }.candidate-head, .match-card-head, .extracted-contact-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }.match-confidence, .contact-confidence { color: var(--teal); background: var(--teal-soft); }.match-reasons { margin: 8px 0; padding-left: 17px; color: var(--muted); font-size: 12px; }.extracted-contact-facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; margin: 10px 0 0; }.contact-source { grid-column: 1 / -1; }.extracted-contact-facts a { color: var(--teal); }.dedup-panel { margin-top: 14px; padding: 15px; border: 1px solid var(--line); border-radius: 10px; background: #f8fbfa; }.merge-dialog { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 22px; background: rgba(13,27,42,.62); }.merge-dialog-card { width: min(100%, 540px); padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: 0 25px 80px rgba(13,27,42,.3); }.merge-dialog-card h2 { margin: 2px 0; }.merge-warning { margin: 15px 0; padding: 12px; border: 1px solid #ead6a9; border-radius: 9px; color: #77500c; background: var(--amber-soft); font-size: 12px; }.merge-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.lower-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; }.add-panel, .csv-panel { padding: 20px; } .lower-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; }.add-panel, .csv-panel { padding: 20px; }
footer { display: flex; justify-content: space-between; gap: 16px; margin-top: 34px; padding: 20px 0 5px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; } footer a { color: var(--teal); font-weight: 700; } footer { display: flex; justify-content: space-between; gap: 16px; margin-top: 34px; padding: 20px 0 5px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; } footer a { color: var(--teal); font-weight: 700; }
.ai-provider-settings-section { scroll-margin-top: 90px; }
.ai-provider-safety { margin: 14px 0; padding: 12px 15px; border: 1px solid #c8e2de; border-radius: 9px; color: #216765; background: var(--teal-soft); font-size: 12px; }
.ai-provider-grid { display: grid; grid-template-columns: minmax(0, 1.18fr) minmax(300px, .82fr); gap: 18px; }
.ai-provider-form-panel, .ai-provider-status-panel { min-width: 0; padding: 20px; }
.ai-provider-form-panel form { display: grid; gap: 14px; margin-top: 17px; }
.ai-provider-status-panel { align-self: start; }
.ai-provider-status-list { display: grid; gap: 10px; margin-top: 17px; }
.ai-provider-status-row { padding: 13px; border: 1px solid var(--line); border-radius: 10px; background: #f8fbfa; }
.ai-provider-status-row > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.ai-provider-status-row small { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; }
.ai-provider-status-row .provider-status { margin-top: 10px; }
.ai-provider-status-row p { margin: 9px 0 0; color: var(--muted); font-size: 11px; }
.provider-status.configured, .provider-status.connected { color: var(--green); background: var(--green-soft); }
.provider-status.invalid, .provider-status.error, .provider-status.unavailable { color: var(--red); background: var(--red-soft); }
.provider-status.not-configured { color: var(--amber); background: var(--amber-soft); }
.provider-status-safety { margin: 14px 0 0; color: var(--muted); font-size: 11px; }
.ai-provider-admin-state { margin: 14px 0; }
.ai-provider-admin-state p { margin: 4px 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
@media (max-width: 1100px) { .metrics { grid-template-columns: repeat(3, 1fr); } .filters { grid-template-columns: repeat(3, 1fr); } .filters .search-wrap { grid-column: 1 / -1; } .workspace-grid { grid-template-columns: 1fr; } .detail-panel { min-height: 0; } .reports-grid { grid-template-columns: 1fr 1fr; } } @media (max-width: 1100px) { .metrics { grid-template-columns: repeat(3, 1fr); } .filters { grid-template-columns: repeat(3, 1fr); } .filters .search-wrap { grid-column: 1 / -1; } .workspace-grid { grid-template-columns: 1fr; } .detail-panel { min-height: 0; } .reports-grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 820px) { .sidebar { width: 224px; } .main { margin-left: 224px; } .top-actions { gap: 9px; } .user-identity { display: none; } .jobs-grid, .sources-grid, .crm-two-col, .lower-grid { grid-template-columns: 1fr; } .pipeline-board { grid-template-columns: repeat(4, minmax(180px, 1fr)); } } @media (max-width: 820px) { .sidebar { width: 224px; } .main { margin-left: 224px; } .top-actions { gap: 9px; } .user-identity { display: none; } .jobs-grid, .sources-grid, .crm-two-col, .lower-grid, .ai-provider-grid { grid-template-columns: 1fr; } .pipeline-board { grid-template-columns: repeat(4, minmax(180px, 1fr)); } }
@media(max-width:700px) { .sidebar { width: min(286px, 86vw); transform: translateX(-105%); transition: transform .2s ease; } .sidebar.open { transform: translateX(0); } .main { margin-left: 0; } .mobile-menu { display: block; } .topbar { padding: 0 18px; } .crumb { display: none; } .top-actions { margin-left: auto; } .api-status { display: none; } .content { padding: 28px 16px 20px; } .hero { align-items: flex-start; flex-direction: column; margin-bottom: 22px; } .hero .button { width: 100%; } .metrics { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; } .metric-card { padding: 13px; gap: 9px; } .metric-icon { width: 30px; height: 30px; font-size: 16px; } .metric-card h2 { font-size: 23px; } .filters { grid-template-columns: 1fr; } .filters .search-wrap { grid-column: auto; } .saved-view-controls { grid-template-columns: 1fr; } .saved-view-controls .inline-form { display: flex; } .saved-view-controls .inline-form input { flex: 1; } .form-grid, .score-breakdown-summary, .score-meta { grid-template-columns: 1fr; } .form-footer, .jobs-header, .sources-header, .crm-header, .panel-heading { align-items: stretch; flex-direction: column; } .form-footer .button, .jobs-actions .button, .crm-actions .button, .website-scan-actions .button, .contact-extraction-actions .button { width: 100%; } .table-meta { align-items: flex-start; flex-direction: column; } .table-meta > span:first-child { margin-right: 0; } .source-facts, .provider-policy-row dl { grid-template-columns: repeat(2, 1fr); } .provider-policy-row { grid-template-columns: 1fr; } .provider-policy-row .provider-status { justify-self: start; } .reports-grid { grid-template-columns: 1fr; } .website-scan-grid { grid-template-columns: 1fr; } .signal-list { grid-template-columns: repeat(2, 1fr); } .dns-capabilities { grid-template-columns: repeat(2, 1fr); } .dns-capabilities div:last-child { grid-column: 1 / -1; } .extracted-contact-facts { grid-template-columns: repeat(2, 1fr); } .contact-source { grid-column: 1 / -1; } .score-config-row { grid-template-columns: 1fr 1fr; } .score-config-row strong { grid-column: 1 / -1; } .pipeline-board { grid-template-columns: repeat(4, minmax(210px, 1fr)); } footer { align-items: flex-start; flex-direction: column; } .login-card { padding: 30px 24px; } } @media(max-width:700px) { .sidebar { width: min(286px, 86vw); transform: translateX(-105%); transition: transform .2s ease; } .sidebar.open { transform: translateX(0); } .main { margin-left: 0; } .mobile-menu { display: block; } .topbar { padding: 0 18px; } .crumb { display: none; } .top-actions { margin-left: auto; } .api-status { display: none; } .content { padding: 28px 16px 20px; } .hero { align-items: flex-start; flex-direction: column; margin-bottom: 22px; } .hero .button { width: 100%; } .metrics { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; } .metric-card { padding: 13px; gap: 9px; } .metric-icon { width: 30px; height: 30px; font-size: 16px; } .metric-card h2 { font-size: 23px; } .filters { grid-template-columns: 1fr; } .filters .search-wrap { grid-column: auto; } .saved-view-controls { grid-template-columns: 1fr; } .saved-view-controls .inline-form { display: flex; } .saved-view-controls .inline-form input { flex: 1; } .form-grid, .score-breakdown-summary, .score-meta { grid-template-columns: 1fr; } .form-footer, .jobs-header, .sources-header, .crm-header, .panel-heading { align-items: stretch; flex-direction: column; } .form-footer .button, .jobs-actions .button, .crm-actions .button, .website-scan-actions .button, .contact-extraction-actions .button { width: 100%; } .table-meta { align-items: flex-start; flex-direction: column; } .table-meta > span:first-child { margin-right: 0; } .source-facts, .provider-policy-row dl { grid-template-columns: repeat(2, 1fr); } .provider-policy-row { grid-template-columns: 1fr; } .provider-policy-row .provider-status { justify-self: start; } .reports-grid { grid-template-columns: 1fr; } .website-scan-grid { grid-template-columns: 1fr; } .signal-list { grid-template-columns: repeat(2, 1fr); } .dns-capabilities { grid-template-columns: repeat(2, 1fr); } .dns-capabilities div:last-child { grid-column: 1 / -1; } .extracted-contact-facts { grid-template-columns: repeat(2, 1fr); } .contact-source { grid-column: 1 / -1; } .score-config-row { grid-template-columns: 1fr 1fr; } .score-config-row strong { grid-column: 1 / -1; } .pipeline-board { grid-template-columns: repeat(4, minmax(210px, 1fr)); } footer { align-items: flex-start; flex-direction: column; } .login-card { padding: 30px 24px; } }
/* Direct discovery workspace */ /* Direct discovery workspace */