diff --git a/.env.example b/.env.example index cb4cf304..c16e43d5 100644 --- a/.env.example +++ b/.env.example @@ -213,6 +213,13 @@ K1_SCOPE_POLICY_PATH=config/scope_guardrails.yaml KAI_STORAGE_ROOT=/srv/kai K1_ARTIFACTS_HOST_ROOT=/srv/kai/artifacts K1_WORKFLOW_OUTPUT_ROOT=/srv/kai/output +K1_PGP_KEY_SOURCE_DIR=/srv/kai/keys/pgp +K1_PGP_KEY_VAULT_PATH=secret/k1/auth/pgp/kaisonai + +# Optional hunter-account import source and inventory index. +KAI_HUNTER_ACCOUNTS_CSV=/home/k1-admin/Documents/Proton Shit/Proton Pass_export_2026-06-11_1781164711.csv +KAI_HUNTER_ACCOUNTS_INDEX_FILE=/srv/kai/artifacts/hunter-accounts/index.json +KAI_HUNTER_ACCOUNTS_VAULT_INDEX_PATH=k1/hunter-accounts/index # ==================== Security Controls ==================== K1_FORCE_HTTPS=false @@ -260,6 +267,11 @@ K1_STARTUP_VALIDATE_TOOLPACKS=false # Set to false for local platform bring-up without full toolchain install. K1_BOOTSTRAP_REQUIRE_EXTERNAL_TOOLS=true +# Enforce schema migrations on backend startup. +KAI_DB_ENFORCE_MIGRATIONS=true +KAI_DB_AUTO_APPLY_MIGRATIONS=true +KAI_DB_FAIL_ON_DIRTY_SCHEMA=true + # ==================== Toolpacks ==================== # Comma-separated tool IDs to enable beyond catalog defaults. K1_TOOLPACKS_ENABLE= diff --git a/apps/backend/src/core/crypto_artifact_signing.py b/apps/backend/src/core/crypto_artifact_signing.py index 81fb62ae..e9dd017d 100644 --- a/apps/backend/src/core/crypto_artifact_signing.py +++ b/apps/backend/src/core/crypto_artifact_signing.py @@ -60,7 +60,7 @@ def __init__( self, gpg_home: str = None, key_source_dir: str = None, - machine_identity: str = "machine-kaisonai@pm.me" + machine_identity: str = "kaisonai@pm.me" ): """ Initialize the crypto system @@ -68,7 +68,7 @@ def __init__( Args: gpg_home: Path to GnuPG home directory (default: ~/.kai/gpg_home) key_source_dir: Path to Kai PGP-Keys directory (default: /home/user/kai/Kai PGP-Keys) - machine_identity: The machine signing identity (default: machine-kaisonai@pm.me) + machine_identity: The machine signing identity (default: kaisonai@pm.me) """ self.key_source_dir = Path(key_source_dir or os.getenv("K1_PGP_KEY_SOURCE_DIR", "/home/user/kai/Kai PGP-Keys")) self.machine_identity = machine_identity @@ -82,10 +82,7 @@ def __init__( # Key identity mappings self.trusted_identities = { - "admin-kaisonai@pm.me": "Kai Admin", - "user-kaisonai@pm.me": "Kai User", - "infra-kaisonai@pm.me": "Kai Infrastructure", - "machine-kaisonai@pm.me": "Kai Machine" + "kaisonai@pm.me": "Kai Signing", } # Signature and verification logs diff --git a/apps/backend/src/core/hunter_account_inventory.py b/apps/backend/src/core/hunter_account_inventory.py new file mode 100644 index 00000000..d831aeb8 --- /dev/null +++ b/apps/backend/src/core/hunter_account_inventory.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +import json +import logging +import os +import re +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +from .vault_client import SecretNotFoundError, VaultClient, VaultConnectionError + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class HunterAccountRecord: + source_index: int + slug: str + display_name: str + platform_hint: str | None + credential_kind: str + username: str | None + email: str | None + source_url: str | None + vault_path: str + has_password: bool + has_totp: bool + has_backup_codes: bool + + +@dataclass(slots=True) +class HunterAccountEnvOverride: + key: str + value: str + source_index: int + source_name: str + source_url: str | None + + +def _env_path(name: str, default: str) -> Path: + raw = (os.getenv(name) or "").strip() + return Path(raw) if raw else Path(default) + + +def hunter_accounts_index_path() -> Path: + return _env_path( + "KAI_HUNTER_ACCOUNTS_INDEX_FILE", + "/srv/kai/artifacts/hunter-accounts/index.json", + ) + + +def hunter_accounts_vault_index_path() -> str: + return (os.getenv("KAI_HUNTER_ACCOUNTS_VAULT_INDEX_PATH") or "k1/hunter-accounts/index").strip() + + +def _slugify(value: str) -> str: + cleaned = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return cleaned or "account" + + +def _host_hint(source_url: str | None, name: str | None, note: str | None) -> str | None: + for raw in (source_url, name, note): + text = str(raw or "").strip().lower() + if not text: + continue + if "hackerone" in text: + return "hackerone" + if "bugcrowd" in text: + return "bugcrowd" + if "intigriti" in text: + return "intigriti" + if "github" in text: + return "github" + if "gitlab" in text: + return "gitlab" + if "priceline" in text: + return "priceline" + if "twilio" in text or "twillio" in text: + return "twilio" + if source_url: + parsed = urlparse(source_url) + host = (parsed.netloc or parsed.path or "").lower().split("@")[-1] + if host: + return host.split(":")[0].split(".")[0] + return None + + +def _credential_kind(name: str, url: str | None, note: str | None, password: str | None, totp: str | None) -> str: + text = " ".join([name or "", url or "", note or ""]).lower() + if any(term in text for term in ("api key", "apikey", "api-key", "token", "bearer")): + return "api_key" + if any(term in text for term in ("hackerone", "bugcrowd", "intigriti", "researcher", "hunter", "bug bounty")): + return "hunter_account" + if password or totp: + return "user_account" + return "user_account" + + +def _backup_codes_present(note: str | None) -> bool: + text = (note or "").lower() + return any(term in text for term in ("backup code", "recovery code", "recovery codes", "backup codes")) + + +def _row_timestamp(row: dict[str, str]) -> int: + raw = (row.get("createTime") or row.get("modifyTime") or "").strip() + try: + return int(raw) + except ValueError: + return 0 + + +def _pick_latest_row(rows: list[dict[str, str]], predicate) -> tuple[int, dict[str, str]] | None: + selected: tuple[int, dict[str, str]] | None = None + selected_timestamp = -1 + for idx, row in enumerate(rows, start=1): + if not predicate(row): + continue + timestamp = _row_timestamp(row) + if selected is None or timestamp > selected_timestamp or ( + timestamp == selected_timestamp and idx > selected[0] + ): + selected = (idx, row) + selected_timestamp = timestamp + return selected + + +def _first_regex_match(text: str, patterns: list[str]) -> str | None: + for pattern in patterns: + match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE) + if match: + if match.groups(): + group = next((item for item in match.groups() if item), None) + if group: + return group.strip() + return match.group(0).strip() + return None + + +def _row_blob(row: dict[str, str]) -> str: + return " ".join( + str(row.get(field) or "") + for field in ("type", "name", "url", "email", "username", "password", "note") + ).lower() + + +def _row_value(row: dict[str, str], *fields: str) -> str | None: + for field in fields: + value = (row.get(field) or "").strip() + if value: + return value + return None + + +def _make_override( + key: str, + value: str | None, + *, + source_index: int, + row: dict[str, str], +) -> HunterAccountEnvOverride | None: + if not value: + return None + return HunterAccountEnvOverride( + key=key, + value=value.strip(), + source_index=source_index, + source_name=(row.get("name") or "").strip() or key, + source_url=((row.get("url") or "").strip() or None), + ) + + +def extract_hunter_account_env_overrides(rows: list[dict[str, str]]) -> list[HunterAccountEnvOverride]: + """Extract the runtime-facing env vars that should mirror the CSV export.""" + overrides: list[HunterAccountEnvOverride] = [] + + def add(key: str, value: str | None, source_index: int, row: dict[str, str]) -> None: + override = _make_override(key, value, source_index=source_index, row=row) + if override is not None: + overrides.append(override) + + def latest_match(predicate): + return _pick_latest_row(rows, predicate) + + # OpenRouter + match = latest_match(lambda row: "openrouter" in _row_blob(row)) + if match: + idx, row = match + text = " ".join(filter(None, [row.get("note") or "", row.get("password") or ""])) + add("OPENROUTER_API_KEY", _first_regex_match(text, [r"(sk-or-v1-[A-Za-z0-9_-]+)"]), idx, row) + + # OpenAI + match = latest_match(lambda row: "openai" in _row_blob(row) or "chatgpt" in _row_blob(row)) + if match: + idx, row = match + text = " ".join(filter(None, [row.get("note") or "", row.get("password") or ""])) + add("OPENAI_API_KEY", _first_regex_match(text, [r"(sk-proj-[A-Za-z0-9_-]+)"]), idx, row) + + # HackerOne + match = latest_match(lambda row: "hackerone" in _row_blob(row) or "hacker one" in _row_blob(row)) + if match: + idx, row = match + note_text = row.get("note") or "" + add("HACKERONE_API_KEY", _first_regex_match(note_text, [r"([A-Za-z0-9+/=]{20,})"]), idx, row) + add("HACKERONE_USERNAME", _row_value(row, "username", "email"), idx, row) + add("HACKERONE_USER_ACCOUNT_PASSWORD", _row_value(row, "password"), idx, row) + + # Intigriti + match = latest_match(lambda row: "intigriti" in _row_blob(row)) + if match: + idx, row = match + note_text = row.get("note") or "" + add("INTIGRITI_API_KEY", _first_regex_match(note_text, [r"Intigrity_API-Key=([^\s/]+)", r"API-Key=([^\s/]+)"]), idx, row) + add("INTIGRITI_USERNAME", _row_value(row, "username", "email"), idx, row) + add("INTIGRITI_USER_ACCOUNT_PASSWORD", _row_value(row, "password"), idx, row) + + # Shodan + match = latest_match(lambda row: "shodan" in _row_blob(row)) + if match: + idx, row = match + add("SHODAN_API_KEY", _row_value(row, "note", "password"), idx, row) + + # Hunter services + match = latest_match(lambda row: "hunter.io" in _row_blob(row)) + if match: + idx, row = match + add("HUNTER_IO_API_KEY", _row_value(row, "note", "password"), idx, row) + + match = latest_match(lambda row: "hunter.how" in _row_blob(row)) + if match: + idx, row = match + add("HUNTER_HOW_API_KEY", _row_value(row, "note", "password"), idx, row) + + # GitHub + match = latest_match(lambda row: "github" in _row_blob(row) and bool(_row_value(row, "note", "password", "username"))) + if match: + idx, row = match + note_text = row.get("note") or "" + add("GITHUB_PAT", _first_regex_match(note_text, [r"(github_pat_[A-Za-z0-9_]+)", r"(ghp_[A-Za-z0-9_]+)"]), idx, row) + add("GITHUB_TOKEN", _first_regex_match(note_text, [r"(github_pat_[A-Za-z0-9_]+)", r"(ghp_[A-Za-z0-9_]+)"]), idx, row) + add("GITHUB_USERNAME", _row_value(row, "username", "email"), idx, row) + + # Google + match = latest_match(lambda row: "google" in _row_blob(row) or "gemini" in _row_blob(row)) + if match: + idx, row = match + note_text = row.get("note") or "" + google_keys = re.findall(r"(AIza[0-9A-Za-z_-]+)", note_text) + if google_keys: + add("GOOGLE_API_KEY", google_keys[0], idx, row) + add("GOOGLE_DEVELOPER_API_KEY", google_keys[0], idx, row) + add("GOOGLE_CSE_API_KEY", google_keys[0], idx, row) + if len(google_keys) > 1: + add("GOOGLE_WORKSPACE_API_KEY", google_keys[1], idx, row) + + # Twilio + match = latest_match(lambda row: "twilio" in _row_blob(row)) + if match: + idx, row = match + add("TWILIO_API_KEY", _row_value(row, "note", "password"), idx, row) + + # Proton + match = latest_match(lambda row: "account.proton.me" in _row_blob(row) or "proton mail" in _row_blob(row)) + if match: + idx, row = match + add("PROTON_ME_EMAIL", _row_value(row, "email", "username"), idx, row) + add("PROTON_ME_PASSWORD", _row_value(row, "password"), idx, row) + + return overrides + + +def inventory_from_csv_rows(rows: list[dict[str, str]], *, source_path: str) -> tuple[list[HunterAccountRecord], dict[str, Any]]: + records: list[HunterAccountRecord] = [] + counts: dict[str, int] = {} + + for idx, row in enumerate(rows, start=1): + row_type = (row.get("type") or "").strip().lower() + name = (row.get("name") or "").strip() + url = (row.get("url") or "").strip() or None + email = (row.get("email") or "").strip() or None + username = (row.get("username") or "").strip() or None + password = (row.get("password") or "").strip() or None + note = (row.get("note") or "").strip() or None + totp = (row.get("totp") or "").strip() or None + + if row_type and row_type not in {"login", "note", "alias"} and not (password or totp): + continue + if not (name or username or email or url or password or totp or note): + continue + + display_name = name or username or email or f"account-{idx}" + credential_kind = _credential_kind(display_name, url, note, password, totp) + platform_hint = _host_hint(url, display_name, note) + slug_source = username or email or display_name or f"account-{idx}" + slug = _slugify(f"{slug_source}-{idx}") + vault_path = f"k1/hunter-accounts/accounts/{credential_kind}/{slug}" + + records.append( + HunterAccountRecord( + source_index=idx, + slug=slug, + display_name=display_name, + platform_hint=platform_hint, + credential_kind=credential_kind, + username=username, + email=email, + source_url=url, + vault_path=vault_path, + has_password=bool(password), + has_totp=bool(totp), + has_backup_codes=_backup_codes_present(note), + ) + ) + counts[credential_kind] = counts.get(credential_kind, 0) + 1 + + summary = { + "source_path": source_path, + "record_count": len(records), + "counts": counts, + "records": [asdict(record) for record in records], + } + return records, summary + + +def load_hunter_account_index(*, client: Any | None = None) -> dict[str, Any]: + vault = client or VaultClient() + try: + raw = vault.read_secret(hunter_accounts_vault_index_path()) + payload = raw.get("summary_json") or raw.get("records_json") + if isinstance(payload, str) and payload.strip(): + return json.loads(payload) + except SecretNotFoundError: + logger.info("Hunter account index not yet imported into Vault.") + except VaultConnectionError as exc: + logger.warning("Hunter account index unavailable: %s", exc) + except json.JSONDecodeError as exc: + logger.warning("Hunter account index JSON is invalid: %s", exc) + return {"source_path": "", "record_count": 0, "counts": {}, "records": []} + + +def write_hunter_account_index(summary: dict[str, Any], *, client: Any | None = None) -> None: + vault = client or VaultClient() + vault.write_secret( + hunter_accounts_vault_index_path(), + { + "summary_json": json.dumps(summary, ensure_ascii=False), + }, + overwrite=True, + ) diff --git a/apps/backend/src/core/kai_orchestrator.py b/apps/backend/src/core/kai_orchestrator.py index 3c6f8211..65869a21 100644 --- a/apps/backend/src/core/kai_orchestrator.py +++ b/apps/backend/src/core/kai_orchestrator.py @@ -384,7 +384,7 @@ def create_permission_slip( class KaiAuditLogger: """ Comprehensive pre-execution audit logging with cryptographic chaining. - Records everything BEFORE tool runs, signs with machine-kaisonai@pm.me. + Records everything BEFORE tool runs, signs with kaisonai@pm.me. """ def __init__(self, log_base_dir: str = "/var/lib/kai/logs/orchestrator"): @@ -767,7 +767,7 @@ class TransparencyEnforcer: MANDATORY_HEADER = ( "[AI-GENERATED REPORT: PRODUCED BY KAISONAI AGENT UNDER HUMAN SUPERVISION]\n" "This report was automatically generated by the KaiOrchestrator middleware.\n" - "All operations are logged and signed with machine-kaisonai@pm.me.\n" + "All operations are logged and signed with kaisonai@pm.me.\n" "For authenticity verification, check the accompanying chain of custody documentation.\n" "---\n\n" ) @@ -1246,4 +1246,3 @@ def get_kai_orchestrator() -> KaiOrchestrator: _global_orchestrator = KaiOrchestrator() return _global_orchestrator - diff --git a/apps/backend/src/core/log_watchdog.py b/apps/backend/src/core/log_watchdog.py index 981b7029..e2139eaa 100644 --- a/apps/backend/src/core/log_watchdog.py +++ b/apps/backend/src/core/log_watchdog.py @@ -124,7 +124,7 @@ async def scan_logs(self) -> Tuple[int, int, List[UnsignedAlert]]: self.signature_records[log_entry.log_id] = SignatureRecord( log_id=log_entry.log_id, signature_file=str(sig_file), - signer="machine-kaisonai@pm.me", + signer="kaisonai@pm.me", signature_valid=True, verified_at=datetime.now(timezone.utc).isoformat() ) diff --git a/apps/backend/src/core/startup_migrations.py b/apps/backend/src/core/startup_migrations.py new file mode 100644 index 00000000..2467097f --- /dev/null +++ b/apps/backend/src/core/startup_migrations.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import logging +import os +from pathlib import Path +from types import SimpleNamespace + +from sqlalchemy import create_engine, inspect + +try: + from alembic import command + from alembic.config import Config + from alembic.runtime.migration import MigrationContext + from alembic.script import ScriptDirectory +except ImportError: # pragma: no cover - test/runtime environments without alembic + command = SimpleNamespace(upgrade=None) # type: ignore[assignment] + Config = object # type: ignore[assignment] + MigrationContext = object # type: ignore[assignment] + ScriptDirectory = object # type: ignore[assignment] + +logger = logging.getLogger(__name__) + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[4] + + +def _database_url() -> str: + url = (os.getenv("DATABASE_URL") or "").strip() + if not url: + raise RuntimeError("DATABASE_URL is not configured; cannot run startup migrations.") + return url.replace("postgresql+asyncpg://", "postgresql://", 1) + + +def _alembic_config() -> Config: + if getattr(command, "upgrade", None) is None: + raise RuntimeError("alembic is not installed; cannot run startup migrations.") + ini_path = _repo_root() / "alembic.ini" + if not ini_path.exists(): + raise RuntimeError(f"Alembic configuration not found at {ini_path}") + cfg = Config(str(ini_path)) + cfg.set_main_option("sqlalchemy.url", _database_url()) + return cfg + + +def _migration_flags() -> tuple[bool, bool, bool]: + enforce = _env_bool("KAI_DB_ENFORCE_MIGRATIONS", True) + auto_apply_default = os.getenv("ENVIRONMENT", "development").strip().lower() != "production" + auto_apply = _env_bool("KAI_DB_AUTO_APPLY_MIGRATIONS", auto_apply_default) + fail_on_dirty = _env_bool("KAI_DB_FAIL_ON_DIRTY_SCHEMA", True) + return enforce, auto_apply, fail_on_dirty + + +def _current_heads(config: Config) -> tuple[str, ...]: + if getattr(command, "upgrade", None) is None: + raise RuntimeError("alembic is not installed; cannot run startup migrations.") + engine = create_engine(_database_url(), future=True) + try: + with engine.connect() as conn: + migration_context = MigrationContext.configure(conn) + return tuple(migration_context.get_current_heads()) + finally: + engine.dispose() + + +def ensure_startup_migrations() -> None: + """Ensure the configured database schema matches the current Alembic heads.""" + if getattr(command, "upgrade", None) is None: + logger.info("Startup migrations skipped because alembic is unavailable.") + return + enforce, auto_apply, fail_on_dirty = _migration_flags() + if not enforce: + logger.info("Startup migrations disabled by configuration.") + return + + cfg = _alembic_config() + script = ScriptDirectory.from_config(cfg) + known_heads = tuple(script.get_heads()) + + engine = create_engine(_database_url(), future=True) + try: + with engine.connect() as conn: + migration_context = MigrationContext.configure(conn) + current_heads = tuple(migration_context.get_current_heads()) + inspector = inspect(conn) + table_names = {name for name in inspector.get_table_names() if name != "alembic_version"} + unknown_heads = [head for head in current_heads if script.get_revision(head) is None] + + logger.info( + "Database migration state: current_heads=%s known_heads=%s tables=%s", + ",".join(current_heads) or "", + ",".join(known_heads) or "", + len(table_names), + ) + + if unknown_heads: + raise RuntimeError( + "Database migration state is unknown or divergent: " + + ", ".join(unknown_heads) + ) + + if not current_heads and table_names and fail_on_dirty: + raise RuntimeError( + "Database schema exists but alembic_version is empty; " + "refusing to boot against an unknown schema." + ) + + if current_heads and set(current_heads) == set(known_heads): + logger.info("Database schema is current at revision(s): %s", ", ".join(current_heads)) + return + + if not auto_apply: + raise RuntimeError( + "Database migrations are pending but auto-apply is disabled. " + f"Current={','.join(current_heads) or ''} expected={','.join(known_heads) or ''}." + ) + finally: + engine.dispose() + + logger.info("Applying Alembic migrations to heads...") + command.upgrade(cfg, "heads") + + refreshed_heads = _current_heads(cfg) + if set(refreshed_heads) != set(known_heads): + raise RuntimeError( + "Alembic migration upgrade completed but the schema did not reach the expected heads. " + f"Current={','.join(refreshed_heads) or ''} expected={','.join(known_heads) or ''}." + ) + logger.info("Database migrations applied successfully: %s", ", ".join(refreshed_heads)) +from types import SimpleNamespace diff --git a/apps/backend/src/core/vault_client.py b/apps/backend/src/core/vault_client.py index 1e453117..14dce206 100644 --- a/apps/backend/src/core/vault_client.py +++ b/apps/backend/src/core/vault_client.py @@ -21,7 +21,13 @@ import hvac from hvac.exceptions import VaultError, InvalidPath except ImportError: - raise ImportError("hvac package required. Install: pip install hvac") + hvac = None # type: ignore[assignment] + + class VaultError(Exception): + """Fallback Vault error used when hvac is unavailable.""" + + class InvalidPath(Exception): + """Fallback invalid-path error used when hvac is unavailable.""" logger = logging.getLogger(__name__) @@ -99,6 +105,10 @@ def __init__( def _connect(self) -> None: """Create or refresh Vault client connection.""" + if hvac is None: + logger.error("Failed to connect to Vault: hvac package is unavailable") + self.client = None + return try: self.client = hvac.Client( url=self.vault_addr, @@ -207,6 +217,8 @@ def write_secret( """ if not self.client: self._connect() + if not self.client: + raise VaultConnectionError("Vault client is unavailable") # Check if secret exists if not overwrite: @@ -247,6 +259,8 @@ def read_secret(self, secret_path: str) -> Dict[str, str]: """ if not self.client: self._connect() + if not self.client: + raise VaultConnectionError("Vault client is unavailable") def _read(): response = self.client.secrets.kv.v2.read_secret_version(path=secret_path) @@ -273,6 +287,8 @@ def list_secrets(self, secret_prefix: str) -> List[str]: """ if not self.client: self._connect() + if not self.client: + raise VaultConnectionError("Vault client is unavailable") try: response = self.client.secrets.kv.v2.list_secrets(path=secret_prefix) @@ -299,6 +315,8 @@ def delete_secret(self, secret_path: str) -> bool: """ if not self.client: self._connect() + if not self.client: + raise VaultConnectionError("Vault client is unavailable") # Check if exists first try: @@ -362,6 +380,8 @@ def get_secret_metadata(self, secret_path: str) -> Dict[str, Any]: """ if not self.client: self._connect() + if not self.client: + raise VaultConnectionError("Vault client is unavailable") try: response = self.client.secrets.kv.v2.read_secret_metadata(path=secret_path) diff --git a/apps/backend/src/main.py b/apps/backend/src/main.py index b590c03d..185af383 100644 --- a/apps/backend/src/main.py +++ b/apps/backend/src/main.py @@ -19,6 +19,7 @@ from apps.backend.src.core.exception_handlers import register_exception_handlers from apps.backend.src.core.services import Services from apps.backend.src.core.tools import get_registry, initialize_default_tools +from apps.backend.src.core.startup_migrations import ensure_startup_migrations from apps.backend.src.core.toolpacks import validate_toolpacks_or_raise from apps.backend.src.core.secret_manager import get_secret_manager from apps.backend.src.core.auth import assert_bootstrap_auth_safe, AuthConfigError @@ -201,6 +202,7 @@ async def lifespan(app: FastAPI): raise RuntimeError(str(exc)) from exc if not test_mode: await services.startup() + ensure_startup_migrations() await ensure_bootstrap_admin_user() if _env_bool("K1_STARTUP_VALIDATE_DEPENDENCIES", True): await _validate_startup_dependencies() diff --git a/apps/backend/src/routers/credentials.py b/apps/backend/src/routers/credentials.py index c798df71..af3e761a 100644 --- a/apps/backend/src/routers/credentials.py +++ b/apps/backend/src/routers/credentials.py @@ -3,13 +3,16 @@ from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ..core.auth import User, get_current_user from ..core.credentials_manager import CredentialsManager +from ..core.hunter_account_inventory import load_hunter_account_index from ..core.hil_db import get_db +from ..core.opportunity_catalog import list_filtered from ..core.vault_client import SecretNotFoundError, VaultConnectionError from ..models.campaign import Program from ..models.credential_schemas import ( @@ -39,6 +42,28 @@ ) +class HunterAccountInventoryResponse(BaseModel): + source_path: str + record_count: int + counts: dict[str, int] + records: list[dict] + + +class ScanSuggestionResponse(BaseModel): + opportunity_id: str + name: str + organization: str + platform: str + score: int + reasons: list[str] + matching_accounts: list[str] + account_ready: bool = True + + +class ScanSuggestionListResponse(BaseModel): + items: list[ScanSuggestionResponse] + + # ============================================================================ # Helper Functions # ============================================================================ @@ -200,7 +225,7 @@ async def delete_credential( except SecretNotFoundError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Credential not found", + detail="Credential not found", ) except VaultConnectionError as e: raise HTTPException( @@ -324,3 +349,83 @@ async def upsert_access_metadata( testing_instructions=body.testing_instructions, ) return _metadata_to_response(meta) + + +def _build_scan_suggestions(limit: int = 10) -> list[dict]: + inventory = load_hunter_account_index() + records = inventory.get("records", []) + opportunities = list_filtered(limit=500) + suggestions: list[dict] = [] + + for opportunity in opportunities: + reasons: list[str] = [] + matching_accounts: list[str] = [] + score = 0 + platform = str(opportunity.platform or "").strip().lower() + required_types = { + str(req.kind or "").strip().lower() + for req in getattr(opportunity, "credential_requirements", []) + if getattr(req, "kind", None) + } + + for record in records: + display_name = str(record.get("display_name") or record.get("slug") or "").strip() + if not display_name: + continue + + record_platform = str(record.get("platform_hint") or "").strip().lower() + record_kind = str(record.get("credential_kind") or "").strip().lower() + source_url = str(record.get("source_url") or "").strip().lower() + + if record_platform and platform and record_platform == platform: + score += 40 + reasons.append(f"platform match via {display_name}") + matching_accounts.append(display_name) + continue + + if record_kind in required_types: + score += 20 + reasons.append(f"required credential type covered by {display_name}") + matching_accounts.append(display_name) + continue + + if source_url and any(domain and domain.lower() in source_url for domain in opportunity.scope_domains): + score += 15 + reasons.append(f"scope host aligns with {display_name}") + matching_accounts.append(display_name) + + if score <= 0 or not matching_accounts: + continue + + suggestions.append( + { + "opportunity_id": opportunity.id, + "name": opportunity.name, + "organization": opportunity.organization, + "platform": opportunity.platform, + "score": score, + "reasons": reasons[:4], + "matching_accounts": sorted(set(matching_accounts))[:5], + "account_ready": True, + } + ) + + suggestions.sort( + key=lambda item: (len(item["matching_accounts"]), item["score"]), + reverse=True, + ) + return suggestions[:limit] + + +@router.get("/hunter-accounts", response_model=HunterAccountInventoryResponse) +async def list_hunter_accounts() -> HunterAccountInventoryResponse: + return HunterAccountInventoryResponse(**load_hunter_account_index()) + + +@router.get("/scan-suggestions", response_model=ScanSuggestionListResponse) +async def list_scan_suggestions( + limit: int = Query(default=50, ge=1, le=50), +) -> ScanSuggestionListResponse: + return ScanSuggestionListResponse( + items=[ScanSuggestionResponse(**item) for item in _build_scan_suggestions(limit)] + ) diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 44d4d9f5..06172f5e 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -1,7 +1,13 @@ import os +import sys +from pathlib import Path os.environ.setdefault("K1_TEST_MODE", "true") os.environ.setdefault("ENVIRONMENT", "test") os.environ.setdefault("K1_DEV_TOKEN", "devtoken") os.environ.setdefault("JWT_SECRET_KEY", "test-jwt-secret") os.environ.setdefault("K1_ENABLE_BOOTSTRAP_AUTH", "true") + +ROOT = Path(__file__).resolve().parents[3] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/apps/backend/tests/test_credentials_scan_suggestions.py b/apps/backend/tests/test_credentials_scan_suggestions.py new file mode 100644 index 00000000..4c07a829 --- /dev/null +++ b/apps/backend/tests/test_credentials_scan_suggestions.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from apps.backend.src.routers import credentials as creds + + +def _opportunity(idx: int): + return SimpleNamespace( + id=f"hackerone:program-{idx}", + name=f"Program {idx}", + organization="Example Corp", + platform="hackerone", + credential_requirements=[ + SimpleNamespace( + kind="signup", + label="Hunter account", + signup_url="https://hackerone.com/users/sign_up", + notes="", + required=True, + ) + ], + scope_domains=[f"app{idx}.example.com"], + ) + + +def test_scan_suggestions_only_return_account_ready_matches(monkeypatch): + inventory = { + "records": [ + { + "display_name": "HackerOne main", + "platform_hint": "hackerone", + "credential_kind": "hunter_account", + "source_url": "https://hackerone.com/users/sign_in", + "slug": "hackerone-main", + "source_index": 1, + } + ] + } + + monkeypatch.setattr(creds, "load_hunter_account_index", lambda: inventory) + monkeypatch.setattr(creds, "list_filtered", lambda limit=500: [_opportunity(i) for i in range(60)]) + + suggestions = creds._build_scan_suggestions(limit=50) + + assert len(suggestions) == 50 + assert all(item["account_ready"] is True for item in suggestions) + assert all(item["matching_accounts"] for item in suggestions) + assert all(item["score"] > 0 for item in suggestions) diff --git a/apps/backend/tests/test_hunter_account_inventory.py b/apps/backend/tests/test_hunter_account_inventory.py new file mode 100644 index 00000000..dacc23ad --- /dev/null +++ b/apps/backend/tests/test_hunter_account_inventory.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from apps.backend.src.core.hunter_account_inventory import inventory_from_csv_rows + + +def test_inventory_parses_hunter_accounts_and_keys(): + rows = [ + { + "type": "login", + "name": "HackerOne", + "url": "https://hackerone.com/users/sign_in", + "email": "researcher@example.com", + "username": "researcher01", + "password": "secret-password", + "note": "backup codes stored separately", + "totp": "123456", + "createTime": "", + "modifyTime": "", + "vault": "", + } + ] + + records, summary = inventory_from_csv_rows(rows, source_path="/tmp/proton.csv") + assert summary["record_count"] == 1 + assert summary["counts"]["hunter_account"] == 1 + assert records[0].credential_kind == "hunter_account" + assert records[0].platform_hint == "hackerone" + assert records[0].has_password is True + assert records[0].has_totp is True diff --git a/apps/backend/tests/test_startup_migrations.py b/apps/backend/tests/test_startup_migrations.py new file mode 100644 index 00000000..a76ccea5 --- /dev/null +++ b/apps/backend/tests/test_startup_migrations.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from apps.backend.src.core import startup_migrations as sm + + +class _FakeConn: + def __init__(self): + self.closed = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + self.closed = True + + +class _FakeEngine: + def __init__(self): + self.conn = _FakeConn() + + def connect(self): + return self.conn + + def dispose(self): + return None + + +class _FakeMigrationContext: + def __init__(self, heads): + self._heads = tuple(heads) + + def get_current_heads(self): + return self._heads + + +class _FakeScript: + def __init__(self, heads): + self._heads = tuple(heads) + + def get_heads(self): + return self._heads + + def get_revision(self, rev): + return SimpleNamespace(revision=rev) if rev in self._heads else None + + +def test_startup_migrations_applies_pending_schema(monkeypatch): + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost:5432/kai") + monkeypatch.setenv("KAI_DB_ENFORCE_MIGRATIONS", "true") + monkeypatch.setenv("KAI_DB_AUTO_APPLY_MIGRATIONS", "true") + monkeypatch.setenv("KAI_DB_FAIL_ON_DIRTY_SCHEMA", "true") + monkeypatch.setattr(sm, "_repo_root", lambda: sm.Path("/tmp")) + monkeypatch.setattr(sm, "_alembic_config", lambda: SimpleNamespace()) + monkeypatch.setattr(sm, "create_engine", lambda *args, **kwargs: _FakeEngine()) + monkeypatch.setattr(sm, "inspect", lambda conn: SimpleNamespace(get_table_names=lambda: [])) + monkeypatch.setattr(sm, "MigrationContext", SimpleNamespace(configure=lambda conn: _FakeMigrationContext([]))) + monkeypatch.setattr(sm, "ScriptDirectory", SimpleNamespace(from_config=lambda cfg: _FakeScript(["rev-a"]))) + monkeypatch.setattr(sm, "_current_heads", lambda cfg: ("rev-a",)) + + applied = {} + monkeypatch.setattr(sm.command, "upgrade", lambda cfg, target: applied.setdefault("target", target)) + + sm.ensure_startup_migrations() + assert applied["target"] == "heads" + + +def test_startup_migrations_blocks_dirty_schema(monkeypatch): + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost:5432/kai") + monkeypatch.setenv("KAI_DB_ENFORCE_MIGRATIONS", "true") + monkeypatch.setenv("KAI_DB_AUTO_APPLY_MIGRATIONS", "true") + monkeypatch.setenv("KAI_DB_FAIL_ON_DIRTY_SCHEMA", "true") + monkeypatch.setattr(sm, "_repo_root", lambda: sm.Path("/tmp")) + monkeypatch.setattr(sm, "_alembic_config", lambda: SimpleNamespace()) + monkeypatch.setattr(sm, "create_engine", lambda *args, **kwargs: _FakeEngine()) + monkeypatch.setattr(sm, "inspect", lambda conn: SimpleNamespace(get_table_names=lambda: ["users"])) + monkeypatch.setattr(sm, "MigrationContext", SimpleNamespace(configure=lambda conn: _FakeMigrationContext([]))) + monkeypatch.setattr(sm, "ScriptDirectory", SimpleNamespace(from_config=lambda cfg: _FakeScript(["rev-a"]))) + monkeypatch.setattr(sm.command, "upgrade", lambda cfg, target: None) + + with pytest.raises(RuntimeError, match="unknown schema"): + sm.ensure_startup_migrations() diff --git a/apps/frontend-operator/app/globals.css b/apps/frontend-operator/app/globals.css index 85cf0cbb..48505795 100644 --- a/apps/frontend-operator/app/globals.css +++ b/apps/frontend-operator/app/globals.css @@ -7,62 +7,95 @@ :root { color-scheme: dark; + --shell-bg: #050607; + --page-bg: #0a0f0a; + --panel-bg: #0d140d; + --panel-elevated: #101910; + --border-color: #003300; + --border-strong: #006600; + --text-color: #d9f7dc; + --text-muted: #6a8b70; + --accent-color: #00FF41; + --accent-soft: rgba(0, 255, 65, 0.08); + --highlight-color: #ff9f1a; + --highlight-soft: rgba(255, 159, 26, 0.10); + --input-bg: #060c06; + --input-border: #003300; + --overlay-color: rgba(0, 0, 0, 0.72); +} + +:root[data-theme="light"] { + color-scheme: light; + --shell-bg: #f4f6f8; + --page-bg: #f7fafc; + --panel-bg: #ffffff; + --panel-elevated: #f9fbfd; + --border-color: #d7e0ea; + --border-strong: #b4c2d0; + --text-color: #1f2937; + --text-muted: #5e6b7a; + --accent-color: #0f766e; + --accent-soft: rgba(15, 118, 110, 0.08); + --highlight-color: #ea580c; + --highlight-soft: rgba(234, 88, 12, 0.12); + --input-bg: #ffffff; + --input-border: #c6d2de; + --overlay-color: rgba(15, 23, 42, 0.28); } /* ── Base ─────────────────────────────────────────────────────────────── */ html { - background: #000; + background: var(--shell-bg); } body { - background: #000000; - color: #00FF41; + background: var(--shell-bg); + color: var(--text-color); font-family: "IBM Plex Mono", "Courier New", monospace; -webkit-font-smoothing: antialiased; - /* subtle CRT flicker */ - animation: flicker 0.12s infinite; + transition: background-color 0.18s ease, color 0.18s ease; } /* ── Hacker text effects ──────────────────────────────────────────────── */ h1, h2, h3 { - text-shadow: 0 0 8px #00FF41, 0 0 16px rgba(0,255,65,0.4); + text-shadow: 0 0 8px var(--accent-soft), 0 0 16px rgba(0,255,65,0.14); } a { - color: #00FF41; + color: var(--accent-color); text-decoration: none; } a:hover { - text-shadow: 0 0 8px #00FF41; - color: #00FF41; + text-shadow: 0 0 8px var(--accent-soft); + color: var(--accent-color); } -/* ── Input / form hacker style ───────────────────────────────────────── */ +/* ── Input / form style ───────────────────────────────────────── */ input, textarea, select { - background: #000 !important; - color: #00FF41 !important; - border: 1px solid #003300 !important; + background: var(--input-bg) !important; + color: var(--text-color) !important; + border: 1px solid var(--input-border) !important; font-family: "IBM Plex Mono", monospace !important; - caret-color: #00FF41; + caret-color: var(--accent-color); } input:focus, textarea:focus, select:focus { - border-color: #00FF41 !important; - box-shadow: 0 0 8px rgba(0,255,65,0.4) !important; + border-color: var(--accent-color) !important; + box-shadow: 0 0 8px var(--accent-soft) !important; outline: none !important; } input::placeholder, textarea::placeholder { - color: #007A1E !important; + color: var(--text-muted) !important; } option { - background: #000; - color: #00FF41; + background: var(--shell-bg); + color: var(--text-color); } /* ── Scrollbar ───────────────────────────────────────────────────────── */ ::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: #000; } -::-webkit-scrollbar-thumb { background: #003300; border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: #007A1E; } +::-webkit-scrollbar-track { background: var(--shell-bg); } +::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--border-strong); } /* ── CRT scanline overlay ────────────────────────────────────────────── */ .scanline-overlay { @@ -79,25 +112,6 @@ option { ); } -/* Moving bright scanline */ -.scanline-sweep::after { - content: ""; - position: fixed; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(transparent, rgba(0,255,65,0.06), transparent); - animation: scanline-sweep 6s linear infinite; - pointer-events: none; - z-index: 9999; -} - -@keyframes scanline-sweep { - 0% { top: -4px; } - 100% { top: 100vh; } -} - -/* ── Vignette ────────────────────────────────────────────────────────── */ .crt-vignette { pointer-events: none; position: fixed; @@ -115,25 +129,19 @@ option { position: relative; } -.operator-grid { - display: grid; - gap: 1rem; -} - .operator-panel { border-radius: 0.5rem; - border: 1px solid #003300; + border: 1px solid var(--border-color); background: rgba(5,5,5,0.92); - box-shadow: inset 0 0 30px rgba(0,255,65,0.03), 0 0 1px #003300; + box-shadow: inset 0 0 30px rgba(0,255,65,0.03), 0 0 1px var(--border-color); } .operator-panel-elevated { border-radius: 0.5rem; - border: 1px solid #003300; + border: 1px solid var(--border-color); background: rgba(10,10,10,0.92); } -/* ── Cards & panels ──────────────────────────────────────────────────── */ .cockpit-flightdeck { background-image: linear-gradient(140deg, rgba(0,255,65,0.06) 0%, rgba(0,0,0,0.96) 55%); } @@ -144,14 +152,14 @@ option { .cockpit-kpi { border-radius: 0.375rem; - border: 1px solid #003300; + border: 1px solid var(--border-color); background: rgba(10,10,10,0.8); padding: 0.75rem; } .cockpit-lane { border-radius: 0.375rem; - border: 1px solid #003300; + border: 1px solid var(--border-color); background: rgba(10,10,10,0.7); padding: 0.75rem; } @@ -160,7 +168,7 @@ option { .cursor-blink::after { content: "_"; animation: blink 1s step-end infinite; - color: #00FF41; + color: var(--accent-color); } @keyframes blink { @@ -182,24 +190,24 @@ option { 100% { transform: translate(0); } } -/* ── Table hacker style ──────────────────────────────────────────────── */ +/* ── Table style ──────────────────────────────────────────────────────── */ table { border-collapse: collapse; width: 100%; } th { - color: #FFD700 !important; + color: var(--highlight-color) !important; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; font-size: 0.7rem; - border-bottom: 1px solid #003300; + border-bottom: 1px solid var(--border-color); } -td { border-bottom: 1px solid #001a00; } -tr:hover td { background: rgba(0,255,65,0.04); } +td { border-bottom: 1px solid rgba(0,0,0,0.08); } +tr:hover td { background: var(--accent-soft); } /* ── Badges / status ─────────────────────────────────────────────────── */ -.badge-success { color: #00FF41; border: 1px solid #003300; } +.badge-success { color: var(--accent-color); border: 1px solid var(--border-color); } .badge-danger { color: #FF3333; border: 1px solid #660000; } -.badge-warning { color: #FFD700; border: 1px solid #665500; } -.badge-muted { color: #007A1E; border: 1px solid #003300; } +.badge-warning { color: var(--highlight-color); border: 1px solid #665500; } +.badge-muted { color: var(--text-muted); border: 1px solid var(--border-color); } /* ── Flicker keyframe ────────────────────────────────────────────────── */ @keyframes flicker { diff --git a/apps/frontend-operator/app/hunter-accounts/page.tsx b/apps/frontend-operator/app/hunter-accounts/page.tsx index 9ccbd874..0ac1b1b8 100644 --- a/apps/frontend-operator/app/hunter-accounts/page.tsx +++ b/apps/frontend-operator/app/hunter-accounts/page.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from "react"; import { useQueries, useQuery } from "@tanstack/react-query"; import { bugBountyApi } from "@/lib/api/bug-bounty"; -import { listCredentials } from "@/lib/api/credentials"; +import { getHunterAccountInventory, getScanSuggestions, listCredentials } from "@/lib/api/credentials"; import { queryKeys } from "@/lib/query-keys"; import type { ProgramOpportunity } from "@/lib/types/bug-bounty"; import { HunterAccountDrawer } from "@/components/credentials/HunterAccountDrawer"; @@ -12,18 +12,22 @@ import { PageHeader } from "@/components/layout/PageHeader"; // ── Colour palette ──────────────────────────────────────────────────────────── const C = { - bg: "#0a0f0a", - panel: "#0d140d", - border: "#003300", - borderActive: "#006600", - green: "#00FF41", - greenDim: "#007A1E", - greenFaint: "rgba(0,255,65,0.07)", - orange: "#ff9900", - muted: "#004d10", - red: "#ff4444", - text: "#00e536", - inputBg: "#060c06", + bg: "var(--page-bg)", + panel: "var(--panel-bg)", + panelSoft: "var(--panel-elevated)", + border: "var(--border-color)", + borderActive: "var(--border-strong)", + green: "var(--accent-color)", + greenDim: "var(--text-muted)", + greenFaint: "var(--accent-soft)", + orange: "var(--highlight-color)", + highlight: "var(--highlight-color)", + highlightSoft: "var(--highlight-soft)", + muted: "var(--text-muted)", + red: "var(--highlight-color)", + text: "var(--text-color)", + textStrong: "var(--text-color)", + inputBg: "var(--input-bg)", } as const; // ── SortOrder ───────────────────────────────────────────────────────────────── @@ -320,6 +324,7 @@ export default function HunterAccountsPage() { const [search, setSearch] = useState(""); const [platformFilter, setPlatformFilter] = useState("ALL"); const [sortOrder, setSortOrder] = useState("name"); + const [inventoryFilter, setInventoryFilter] = useState<"all" | "complete" | "partial">("all"); // ── Programs query ───────────────────────────────────────────────────────── const programsQuery = useQuery({ @@ -328,6 +333,28 @@ export default function HunterAccountsPage() { staleTime: 120_000, }); const programs = programsQuery.data ?? []; + const hunterInventoryQuery = useQuery({ + queryKey: queryKeys.credentials.hunterInventory(), + queryFn: ({ signal }) => getHunterAccountInventory(signal), + staleTime: 120_000, + }); + const scanSuggestionsQuery = useQuery({ + queryKey: queryKeys.credentials.scanSuggestions(50), + queryFn: ({ signal }) => getScanSuggestions(50, signal), + staleTime: 120_000, + }); + const hunterInventory = hunterInventoryQuery.data; + const scanSuggestions = scanSuggestionsQuery.data?.items ?? []; + const inventoryRecords = useMemo(() => { + const records = hunterInventory?.records ?? []; + if (inventoryFilter === "complete") { + return records.filter((record) => record.username && record.email && record.has_password); + } + if (inventoryFilter === "partial") { + return records.filter((record) => !(record.username && record.email && record.has_password)); + } + return records; + }, [hunterInventory?.records, inventoryFilter]); // ── Batch credential status — one query per program (cached by dot components) ── const credQueries = useQueries({ @@ -465,6 +492,220 @@ export default function HunterAccountsPage() { loading={credQueriesLoading} /> +
+
+
+
+ Imported Hunter Inventory +
+
+ Proton CSV import data with visible presence markers for each account field. +
+
+
+ + {inventoryRecords.length}/{hunterInventory?.record_count ?? 0} records + + {Object.entries(hunterInventory?.counts ?? {}).map(([kind, count]) => ( + + {kind}: {count} + + ))} + +
+
+ +
+ {[ + { key: "all", label: "all inventory" }, + { key: "complete", label: "complete" }, + { key: "partial", label: "needs attention" }, + ].map((item) => { + const active = inventoryFilter === item.key; + return ( + + ); + })} +
+ + {hunterInventoryQuery.isLoading ? ( +
⟳ Loading imported inventory…
+ ) : inventoryRecords.length ? ( +
+ {inventoryRecords.slice(0, 12).map((record) => ( +
+
+
+ {record.display_name} +
+
+ {record.credential_kind} · {record.platform_hint ?? "unknown platform"} +
+
+ {record.source_url ?? record.vault_path} +
+
+ + + + + +
+ ))} +
+ ) : ( +
+ No imported hunter inventory found for the selected filter. The UI is still reading live credential metadata only. +
+ )} +
+ +
+
+
+
+ Suggested Scan Queue +
+
+ Opportunities ranked from imported account coverage and platform match. +
+
+
+ {scanSuggestions.length} suggested +
+
+ {scanSuggestionsQuery.isLoading ? ( +
⟳ Computing scan suggestions…
+ ) : scanSuggestions.length > 0 ? ( +
+ {scanSuggestions.slice(0, 50).map((item) => { + const program = programs.find((p) => p.id === item.opportunity_id); + return ( +
+
+
+ {item.name} +
+
+ {item.organization} · {item.platform} · score {item.score} +
+
+ {item.matching_accounts.map((account) => ( + + {account} + + ))} +
+
+
+ {item.reasons.slice(0, 3).map((reason) => ( + + {reason} + + ))} + +
+
+ ); + })} +
+ ) : ( +
+ No suggestions available yet. Sync the Proton import or add more accounts to improve queue ranking. +
+ )} +
+ {/* ── Filters + Sort ── */}
); } + +function PresenceFlag({ label, present }: { label: string; present: boolean }) { + return ( +
+ {present ? "✓" : "×"} + {label} +
+ ); +} diff --git a/apps/frontend-operator/app/layout.tsx b/apps/frontend-operator/app/layout.tsx index b8c8c04e..4043bda8 100644 --- a/apps/frontend-operator/app/layout.tsx +++ b/apps/frontend-operator/app/layout.tsx @@ -13,10 +13,26 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + +