Compare commits
2
Commits
4f87ca93f6
...
7a010fe865
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a010fe865 | ||
|
|
d07dc77ca1 |
+9
-5
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user