diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2084bc4..abbf713 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "sse-starlette>=2.1,<3", "httpx>=0.27,<1", "argon2-cffi>=23.1,<26", + "cryptography>=43", ] [dependency-groups] diff --git a/backend/src/researchx/adapters/model_factory.py b/backend/src/researchx/adapters/model_factory.py new file mode 100644 index 0000000..4a13952 --- /dev/null +++ b/backend/src/researchx/adapters/model_factory.py @@ -0,0 +1,163 @@ +"""Per-run model adapter factory (T-2.5, REQ-74 — D-110, P-2 closure). + +Resolves the per-run credential chain for the CURRENT user: + + user key (decrypted per-user key store) -> tier "user" + server env-file OLLAMA_API_KEY -> tier "server" + neither (localhost:11434 fallback) -> tier "keyless" + +One adapter instance per ``resolve()`` call — NEVER cached across runs. +The per-instance ``_active_base`` fallback mutation in +OllamaModelAdapter is then per-run by construction, killing the +shared-adapter bug where one user's cloud failure flipped every +subsequent user's run to localhost. + +The keyless tier constructs an adapter with no key (no Authorization +header is sent — see ollama.py) and the existing cloud→local fallback +semantics are preserved: a keyless adapter starts on the cloud base +and falls back on failure exactly as before, so localhost:11434 +remains the zero-cost path. + +Tampered/corrupt key-store envelopes surface as ``KeyStoreError`` (a +typed ``AdapterError``) from ``resolve()`` — the caller decides how +to present the honest error; the factory NEVER silently falls down the +chain on a read failure (that would be a silent tier drop, C3-06). +""" + +import hashlib + +from researchx.adapters.model import ModelRoles +from researchx.adapters.ollama import OllamaModelAdapter +from researchx.adapters.secrets import SecretsProvider +from researchx.auth.key_store import KEY_SCOPE, KeyStore, KeyStoreError +from researchx.auth.models import UserPublic +from researchx.auth.settings import DEFAULT_PLANNER_MODEL, DEFAULT_WRITER_MODEL, UserSettings + +TIER_USER = "user" +TIER_SERVER = "server" +TIER_KEYLESS = "keyless" + + +class ModelAdapterFactory: + """Resolve (adapter, tier) for a user's run — per call, never cached.""" + + def __init__( + self, + secrets: SecretsProvider, + key_store: KeyStore, + *, + planner_model: str = DEFAULT_PLANNER_MODEL, + writer_model: str = DEFAULT_WRITER_MODEL, + cloud_base_url: str | None = None, + local_base_url: str | None = None, + ) -> None: + self._secrets = secrets + self._key_store = key_store + self._planner_model = planner_model + self._writer_model = writer_model + self._cloud_base_url = cloud_base_url + self._local_base_url = local_base_url + + def _construct( + self, planner: str, writer: str, api_key: str | None + ) -> OllamaModelAdapter: + return OllamaModelAdapter( + self._secrets, + planner_model=planner, + writer_model=writer, + cloud_base_url=self._cloud_base_url, + local_base_url=self._local_base_url, + api_key=api_key, + ) + + async def resolve( + self, user: UserPublic, settings: UserSettings + ) -> tuple[OllamaModelAdapter, str]: + """Resolve the user's credential chain and build ONE adapter. + + The adapter's roles honor the user's saved settings + (planner_model/writer_model); an unset settings value keeps the + factory defaults. Raises ``KeyStoreError`` (an AdapterError) + when the user's stored envelope is tampered/corrupt — never a + silent tier drop. + """ + planner = settings.planner_model or self._planner_model + writer = settings.writer_model or self._writer_model + + if self._key_store.enabled: + # get_key raises KeyStoreError on tamper — honest error, not + # a silent fall to server/keyless. + user_key = await self._key_store.get_key(user.user_id) + if user_key: + return self._construct(planner, writer, user_key), TIER_USER + + server_key = self._secrets.resolve(KEY_SCOPE) + if server_key: + return self._construct(planner, writer, server_key), TIER_SERVER + + return self._construct(planner, writer, None), TIER_KEYLESS + + async def resolve_status( + self, user: UserPublic, settings: UserSettings + ) -> tuple[OllamaModelAdapter, str, str | None]: + """Status variant for GET /api/config: like resolve() but a + tampered/corrupt key-store read DEGRADES down the chain and + returns the honest error message instead of raising — the + config status must never 500 on a corrupt envelope, and the + tier must never silently claim 'user' when the key cannot be + decrypted. create_run keeps the raising resolve() (a corrupt + key there is an honest 409 failure). + """ + error: str | None = None + planner = settings.planner_model or self._planner_model + writer = settings.writer_model or self._writer_model + + if self._key_store.enabled: + try: + user_key = await self._key_store.get_key(user.user_id) + except KeyStoreError as exc: + # Honest degradation: the tier falls to server/keyless + # but the error message is carried alongside — surfaced + # by the config status so the user can see WHY. + user_key = None + error = str(exc) + if user_key: + return self._construct(planner, writer, user_key), TIER_USER, None + + server_key = self._secrets.resolve(KEY_SCOPE) + if server_key: + return self._construct(planner, writer, server_key), TIER_SERVER, error + + return self._construct(planner, writer, None), TIER_KEYLESS, error + + async def key_fingerprint(self, user: UserPublic) -> str: + """Stable per-(user, tier, key) probe-cache key component. + + The raw key is NEVER the fingerprint — a short sha256 of the + effective key (or the tier itself when keyless) identifies the + resolution without exposing material. A key-store read error + yields the server/keyless fingerprint; the probe itself will + surface the error state through the config status. + """ + if self._key_store.enabled: + try: + user_key = await self._key_store.get_key(user.user_id) + except KeyStoreError: + user_key = None + if user_key: + return f"{TIER_USER}:{hashlib.sha256(user_key.encode()).hexdigest()[:12]}" + server_key = self._secrets.resolve(KEY_SCOPE) + if server_key: + return f"{TIER_SERVER}:{hashlib.sha256(server_key.encode()).hexdigest()[:12]}" + return TIER_KEYLESS + + def roles_defaults(self) -> ModelRoles: + return ModelRoles(planner=self._planner_model, writer=self._writer_model) + + +__all__ = [ + "TIER_KEYLESS", + "TIER_SERVER", + "TIER_USER", + "ModelAdapterFactory", +] \ No newline at end of file diff --git a/backend/src/researchx/adapters/ollama.py b/backend/src/researchx/adapters/ollama.py index 5558af1..9d81d4f 100644 --- a/backend/src/researchx/adapters/ollama.py +++ b/backend/src/researchx/adapters/ollama.py @@ -3,7 +3,7 @@ from typing import Any import httpx from researchx.adapters.model import ModelRoles -from researchx.adapters.secrets import EnvFileSecretsProvider +from researchx.adapters.secrets import SecretsProvider from researchx.adapters.types import AdapterError, ChatResult, ModelMessage, Usage _DEFAULT_CLOUD_BASE = "https://ollama.com/v1" @@ -16,18 +16,22 @@ class OllamaModelAdapter: def __init__( self, - secrets: EnvFileSecretsProvider, + secrets: SecretsProvider, planner_model: str, writer_model: str, cloud_base_url: str | None = None, local_base_url: str | None = None, + api_key: str | None = None, ) -> None: self._secrets = secrets self._roles = ModelRoles(planner=planner_model, writer=writer_model) self._cloud_base = (cloud_base_url or secrets.resolve("OLLAMA_BASE_URL") or _DEFAULT_CLOUD_BASE).rstrip("/") self._local_base = (local_base_url or _DEFAULT_LOCAL_BASE).rstrip("/") self._active_base = self._cloud_base - self._api_key = secrets.resolve("OLLAMA_API_KEY") + # Explicit api_key takes precedence over the secrets resolve + # (T-2.5: the per-run factory passes the user's decrypted key); + # the secrets path stays for back-compat (server tier). + self._api_key = api_key if api_key is not None else secrets.resolve("OLLAMA_API_KEY") self._last_fallback_reason: str | None = None def _headers(self, base_url: str) -> dict[str, str]: @@ -99,12 +103,19 @@ class OllamaModelAdapter: raise AdapterError(f"models listing failed ({type(exc).__name__})") from exc data: dict[str, Any] = response.json() return [str(entry["id"]) for entry in data.get("data", [])] - except (AdapterError, httpx.HTTPError): - if self._active_base == self._cloud_base: + except (AdapterError, httpx.HTTPError) as exc: + # Fallback only if there IS a distinct local base to fall + # back to (a caller pinning both bases to one endpoint — + # e.g. a private gateway — must not recurse forever), and + # never leak a raw httpx error: the adapter's outward + # contract is a typed AdapterError. + if self._active_base == self._cloud_base and self._local_base != self._cloud_base: self._last_fallback_reason = "cloud models listing failed; using local" self._active_base = self._local_base return await self.list_models() - raise + if isinstance(exc, AdapterError): + raise + raise AdapterError(f"models listing failed ({type(exc).__name__})") from exc def roles(self) -> ModelRoles: return self._roles diff --git a/backend/src/researchx/api/app.py b/backend/src/researchx/api/app.py index 0545e24..200520a 100644 --- a/backend/src/researchx/api/app.py +++ b/backend/src/researchx/api/app.py @@ -1,4 +1,5 @@ import asyncio +import time from collections.abc import AsyncIterator from pathlib import Path from typing import Annotated, Any @@ -11,12 +12,15 @@ from sse_starlette.sse import EventSourceResponse from researchx.adapters.capability_probe import probe_model_adapter, supports_probe from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter from researchx.adapters.local_storage import LocalStorageAdapter +from researchx.adapters.model_factory import ModelAdapterFactory from researchx.adapters.types import AdapterError, ChatResult from researchx.auth.admin_routes import build_admin_router from researchx.auth.guard import Guard, build_admin_guard, build_dev_guard, build_guard +from researchx.auth.key_store import KEY_SCOPE, KeyStore, KeyStoreError from researchx.auth.models import UserPublic from researchx.auth.routes import build_auth_router from researchx.auth.sessions import SessionStore +from researchx.auth.settings import SettingsStore, SettingsStoreError, UserSettings from researchx.auth.users import UserStore from researchx.config.paths import data_root from researchx.engine.actor import RunActor, make_run_config @@ -36,6 +40,10 @@ _CANNED_WRITER = ( _CORS_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"] +# Config-status probe cache TTL (P-1): in-process, keyed +# (user_id, tier, key_fingerprint) so a key change invalidates. +_PROBE_CACHE_TTL_SECONDS = 60.0 + class CreateRunBody(BaseModel): question: str = Field(min_length=1, max_length=2000) @@ -48,6 +56,42 @@ class ApproveBody(BaseModel): plan: dict[str, Any] | None = None +class SettingsBody(BaseModel): + """PUT /api/config/settings body — partial update (D-111).""" + + planner_model: str | None = Field(default=None, min_length=1, max_length=200) + writer_model: str | None = Field(default=None, min_length=1, max_length=200) + methodology_defaults: dict[str, Any] | None = None + appearance: str | None = Field(default=None, max_length=40) + + +class ModelKeyBody(BaseModel): + """PUT /api/config/settings/model-key body — the raw key, accepted, + never echoed (REQ-73).""" + + key: str = Field(min_length=1, max_length=512) + + +class KeyStatus(BaseModel): + """Masked-only key status — the raw key NEVER appears (REQ-73).""" + + configured: bool + masked: str | None = None + disabled: bool = False + reason: str | None = None + + +class ConfigStatus(BaseModel): + resolved_tier: str # user | server | keyless (REQ-74) + key_store: str # enabled | disabled + key_status: KeyStatus + model_adapter: str + search_adapter: str + storage_dir: str + probe: dict[str, Any] | None = None + cost_counters: dict[str, int] + + class RunSummary(BaseModel): id: str status: str @@ -68,14 +112,6 @@ class MethodologyCard(BaseModel): dials: dict[str, Any] -class ConfigStatus(BaseModel): - model_adapter: str - search_adapter: str - storage_dir: str - probe: dict[str, Any] | None = None - cost_counters: dict[str, int] - - _STATUS_MAP = { RunStateName.PLANNING: "planning", RunStateName.AWAITING_APPROVAL: "awaiting_approval", @@ -222,6 +258,10 @@ def create_app( auth_enabled: bool = True, cookie_secure: bool = False, data_dir: Path | None = None, + key_store: KeyStore | None = None, + settings_store: SettingsStore | None = None, + model_factory: ModelAdapterFactory | None = None, + secrets_provider: Any = None, ) -> FastAPI: """Build the ResearchX API app. @@ -230,6 +270,14 @@ def create_app( dev admin for every request so zero-key suites keep working. ``data_dir`` overrides the auth-store location (defaults to ``data_root()``); ``storage_dir`` remains the run-artifact base. + + Per-user stores (T-2.2/T-2.3): ``key_store`` defaults to a KeyStore + over the data root (disabled when no KEK is configured — honest + degradation); ``settings_store`` likewise. Both are injectable for + tests. ``model_factory`` (T-2.5) resolves the per-user credential + chain user → server → keyless for ``create_run`` and + ``GET /api/config``; when absent the fixed ``model_adapter`` is + used (test harness), tiered resolution disabled by construction. """ app = FastAPI(title="ResearchX API", version="0.2.0") @@ -237,21 +285,44 @@ def create_app( # overrides for tests); auth stores always share the same root. storage_base = storage_dir if storage_dir is not None else data_root() runs = RunRegistry() - model = model_adapter or FakeModelAdapter( - canned=[ - ChatResult(content=_CANNED_PLAN, model="fake-planner"), - ChatResult(content=_CANNED_WRITER, model="fake-writer"), - ] - ) + # The fallback model is constructed ONLY when no factory is wired: + # the factory path (production, T-2.5) resolves a real adapter per + # run and the production entrypoint can therefore never hold a + # FakeModelAdapter in memory (P-3). + model: Any + if model_adapter is not None: + model = model_adapter + elif model_factory is not None: + model = None # every consumer goes through the factory + else: + model = FakeModelAdapter( + canned=[ + ChatResult(content=_CANNED_PLAN, model="fake-planner"), + ChatResult(content=_CANNED_WRITER, model="fake-writer"), + ] + ) search = search_adapter or FakeSearchAdapter(CORPUS) auth_root = data_dir if data_dir is not None else data_root() users = UserStore(auth_root / "users.json") sessions = SessionStore(auth_root / "sessions.json") + # Per-user stores wired from the same root as the auth stores; + # injectable for tests (key_store=None -> disabled, honest reason). + key_store = key_store if key_store is not None else KeyStore( + secrets_provider or _NullSecrets(), auth_root + ) + settings_store = settings_store if settings_store is not None else SettingsStore(auth_root) + guard: Guard = build_guard(users, sessions) if auth_enabled else build_dev_guard() admin_guard = build_admin_guard(guard) + # In-process probe cache keyed (user_id, tier, key_fingerprint) + # with a short TTL (P-1 closure — the probe runs against the + # RESOLVED tier, never stolen from the registry, and a key change + # rotates the fingerprint which invalidates the entry). + probe_cache: dict[tuple[str, str, str], tuple[float, dict[str, Any] | None]] = {} + def _owner_storage(user: UserPublic) -> LocalStorageAdapter: """Owner-scoped storage, constructed per request from the auth dependency (REQ-71): runs///.""" @@ -269,6 +340,24 @@ def create_app( started_at=state.created_at.isoformat(), ) + async def _masked_key_status(user: UserPublic) -> KeyStatus: + """Masked-only key status — never the raw key (REQ-73).""" + try: + status = await key_store.masked_status(user.user_id) + except KeyStoreError as exc: + # Stored-but-unreadable: surface the error state honestly. + return KeyStatus( + configured=False, masked=None, disabled=not key_store.enabled, reason=str(exc) + ) + masked = status.get("masked") + reason = status.get("reason") + return KeyStatus( + configured=bool(status.get("configured")), + masked=str(masked) if masked is not None else None, + disabled=bool(status.get("disabled")), + reason=str(reason) if reason is not None else None, + ) + # ------------------------------------------------------------------ # PUBLIC surface: /healthz outside /api (D-103 — plain GET for the # TLS-front proxy, never auth-wrapped) + /api/auth/* (no @@ -289,20 +378,103 @@ def create_app( protected.include_router(build_admin_router(users, sessions, admin_guard)) - @protected.get("/api/config") + @protected.get("/api/config", response_model=ConfigStatus) async def config_status(user: Annotated[UserPublic, Depends(guard)]) -> ConfigStatus: - latest_probe = None - for owner_and_run in list(runs._actors)[-1:]: - if owner_and_run[0] == user.user_id: - latest_probe = runs.actor(*owner_and_run).state.probe + """Current user's resolved credential tier + status (T-2.6). + + P-1 closure: the probe is resolved via the factory for the + CURRENT user — no run creation, no reads of the private + registry dict, correct with zero runs, no cross-user leak. + A corrupt/tampered stored envelope degrades the tier honestly + (server/keyless) and surfaces the error in key_status.reason — + never a 500, never a silent drop. + """ + tier = "keyless" + adapter_name = type(model).__name__ if model is not None else "unknown" + probe: dict[str, Any] | None = None + resolve_error: str | None = None + + if model_factory is not None: + settings = await settings_store.get(user.user_id) + adapter, tier, resolve_error = await model_factory.resolve_status(user, settings) + adapter_name = type(adapter).__name__ + fingerprint = await model_factory.key_fingerprint(user) + cache_key = (user.user_id, tier, fingerprint) + now = time.monotonic() + cached = probe_cache.get(cache_key) + if cached is not None and now - cached[0] < _PROBE_CACHE_TTL_SECONDS: + probe = cached[1] + elif supports_probe(adapter): + try: + probe_result = await probe_model_adapter(adapter) + probe = probe_result.model_dump(mode="json") + probe_cache[cache_key] = (now, probe) + except AdapterError: + probe = None + probe_cache[cache_key] = (now, None) + key_status = await _masked_key_status(user) + if resolve_error is not None and key_status.reason is None: + # The tier degraded honestly; say WHY (never silently). + key_status = key_status.model_copy(update={"reason": resolve_error}) return ConfigStatus( - model_adapter=type(model).__name__, + resolved_tier=tier, + key_store="enabled" if key_store.enabled else "disabled", + key_status=key_status, + model_adapter=adapter_name, search_adapter=type(search).__name__, storage_dir=str(storage_base), - probe=latest_probe, + probe=probe, cost_counters=runs.completed_snapshot(user.user_id), ) + @protected.get("/api/config/settings", response_model=UserSettings) + async def get_settings(user: Annotated[UserPublic, Depends(guard)]) -> UserSettings: + return await settings_store.get(user.user_id) + + @protected.put("/api/config/settings", response_model=UserSettings) + async def put_settings( + body: SettingsBody, user: Annotated[UserPublic, Depends(guard)] + ) -> UserSettings: + """Persist a partial settings update (REQ-72); returns the + updated settings. An explicit null field is treated as absent + (partial-merge semantics — the store refuses null-valued + fields, so a null can never poison the on-disk payload).""" + updates: dict[str, Any] = { + key: value + for key, value in body.model_dump(exclude_unset=True).items() + if value is not None + } + try: + current = await settings_store.get(user.user_id) + merged = current.model_copy(update=updates) + return await settings_store.update(user.user_id, merged) + except SettingsStoreError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @protected.put("/api/config/settings/model-key", response_model=KeyStatus) + async def put_model_key( + body: ModelKeyBody, user: Annotated[UserPublic, Depends(guard)] + ) -> KeyStatus: + """Accept the user's raw model key, encrypt it at rest, return + ONLY the masked status (REQ-73 — the raw key never appears in + any response, log, or trace). Without a KEK: honest 409.""" + if not key_store.enabled: + # Honest refusal, never a silent no-op. + raise HTTPException( + status_code=409, + detail={ + "key_store": "disabled", + "reason": key_store.disabled_reason, + }, + ) + await key_store.set_key(user.user_id, KEY_SCOPE, body.key) + return await _masked_key_status(user) + + @protected.get("/api/config/settings/model-key", response_model=KeyStatus) + async def get_model_key_status(user: Annotated[UserPublic, Depends(guard)]) -> KeyStatus: + """Masked-only key status (never the raw key).""" + return await _masked_key_status(user) + @protected.get("/api/methodologies") async def methodologies() -> list[MethodologyCard]: cards: list[MethodologyCard] = [] @@ -327,6 +499,24 @@ def create_app( if body.methodology not in registry: raise HTTPException(status_code=404, detail="unknown methodology") storage = _owner_storage(user) + + # Per-run credential resolution (T-2.5, REQ-74): one adapter + # instance per run — the _active_base fallback mutation is + # per-instance by construction (P-2 closure). + tier: str | None = None + if model_factory is not None: + settings = await settings_store.get(user.user_id) + try: + run_model, tier = await model_factory.resolve(user, settings) + except KeyStoreError as exc: + # A tampered/corrupt stored key is an honest failure — + # never a silent tier drop and never a wrong-key run. + raise HTTPException(status_code=409, detail=str(exc)) from exc + else: + # No factory: the fixed test-harness adapter (create_app + # guarantees one exists whenever the factory is absent). + run_model = model + try: run_id, config = make_run_config( question=body.question, @@ -343,7 +533,7 @@ def create_app( bus = EventBus() actor = RunActor( run_config=config, - model=model, + model=run_model, search=search, storage=storage, registry=registry, @@ -352,9 +542,10 @@ def create_app( # Stamp the owner on the persisted state (REQ-71). The actor # itself is untouched — engine contracts unchanged. actor.state.owner_id = user.user_id - if supports_probe(model): + actor.state.credential_tier = tier # REQ-74: auditability + if supports_probe(run_model): try: - probe = await probe_model_adapter(model) + probe = await probe_model_adapter(run_model) await actor.attach_probe(probe.model_dump(mode="json")) except AdapterError: pass @@ -512,8 +703,17 @@ def create_app( return app + +class _NullSecrets: + """No-secrets provider: the default key store is disabled unless a + secrets provider is injected (zero-key test harness stays green).""" + + def resolve(self, name: str) -> str | None: + return None + + # Dev-only module app (README's `uvicorn researchx.api.app:app`). # Pitfall P-12/§8 item 3: this serves FAKES with default storage — # the production entrypoint (researchx.api.main, Phase 2) must never # import this global. Single worker only (in-memory stores by design). -app = create_app() +app = create_app() \ No newline at end of file diff --git a/backend/src/researchx/api/main.py b/backend/src/researchx/api/main.py new file mode 100644 index 0000000..4f4b6ee --- /dev/null +++ b/backend/src/researchx/api/main.py @@ -0,0 +1,87 @@ +"""Production entrypoint (T-2.7 — P-3 backend closure). + +``uvicorn researchx.api.main:app`` — the ONLY serving entry the +container ever runs (T-4.1). Distinct from ``researchx.api.app`` (the +test/dev factory, whose module-global serves fakes): + +- ``auth_enabled=True`` is HARDWIRED — not a parameter here, never an + env var, never False on any path that serves traffic (REQ-85). +- ``cookie_secure=True`` (E-4 handoff): the cookie is set with Secure + because the public surface sits behind the TLS-fronted proxy. +- Run artifacts + auth stores + key store + settings live under + ``RESEARCHX_DATA_DIR`` (container default ``/var/lib/researchx``, + D-107) — never cwd-dependent. +- Secrets come from the runtime env file (``.ciagent/.env.secrets`` + by default, overridable via ``RESEARCHX_SECRETS_FILE``; the + container passes its own env-file via ``--env-file``): + ``RESEARCHX_KEK`` (keys scope) enables the per-user key store, + ``OLLAMA_API_KEY`` the server tier, ``TAVILY_API_KEY`` keyed search. +- The model adapter is ALWAYS the real OllamaModelAdapter, constructed + per run by the ModelAdapterFactory — this module can never serve + FakeModelAdapter (pinned by test_main_entrypoint). +- Search: TavilySearchAdapter when TAVILY_API_KEY is present, else the + documented keyless DuckDuckGo fallback (degraded mode — G-008). +- Single uvicorn worker only (in-memory RunRegistry + session store). +""" + +from pathlib import Path + +from fastapi import FastAPI + +from researchx.adapters.duckduckgo import resolve_search_adapter +from researchx.adapters.model_factory import ModelAdapterFactory +from researchx.adapters.secrets import EnvFileSecretsProvider +from researchx.api.app import create_app +from researchx.auth.key_store import KeyStore +from researchx.auth.settings import SettingsStore +from researchx.config.paths import data_root + +_DEFAULT_SECRETS_PATHS = [ + Path("/etc/researchx/env.secrets"), # container convention (runtime bind) + Path.cwd() / ".ciagent" / ".env.secrets", # repo-local dev + Path.cwd().parents[1] / ".ciagent" / ".env.secrets", # tests run from backend/ +] + + +def _secrets_paths() -> list[Path]: + """Runtime env-file candidates (first existing wins per merge order).""" + import os + + override = os.environ.get("RESEARCHX_SECRETS_FILE") + if override: + return [Path(override)] + return [p for p in _DEFAULT_SECRETS_PATHS if p.is_file()] or _DEFAULT_SECRETS_PATHS + + +def build_app() -> FastAPI: + """Build the LIVE app: real adapters, real auth, data-root storage. + + ``auth_enabled`` and ``cookie_secure`` are not parameters — they + are the production posture, fixed here (C3-16/REQ-85). + """ + secrets = EnvFileSecretsProvider(paths=_secrets_paths()) + root = data_root() + + key_store = KeyStore(secrets, root) + settings_store = SettingsStore(root) + factory = ModelAdapterFactory(secrets, key_store) + + # Tavily when keyed, documented keyless DuckDuckGo otherwise (G-008). + search, _search_name = resolve_search_adapter(secrets) + + return create_app( + storage_dir=root, + data_dir=root, + search_adapter=search, + secrets_provider=secrets, + key_store=key_store, + settings_store=settings_store, + model_factory=factory, + auth_enabled=True, # HARDWIRED — the parameter exists for the test factory only + cookie_secure=True, # behind the TLS-front proxy (E-4) + ) + + +# Module-level app for `uvicorn researchx.api.main:app`. This is the +# production object; it is built from live wiring (never fakes). +app = build_app() \ No newline at end of file diff --git a/backend/src/researchx/auth/key_store.py b/backend/src/researchx/auth/key_store.py new file mode 100644 index 0000000..4037402 --- /dev/null +++ b/backend/src/researchx/auth/key_store.py @@ -0,0 +1,336 @@ +"""Per-user encrypted key store (T-2.2, REQ-73 — D-108/D-109). + +AES-256-GCM envelope encryption of per-user model keys: + +- KEK: ``RESEARCHX_KEK`` from the env-file secrets **'keys' scope**, + accepted as urlsafe-base64 or hex, exactly 32 bytes (AES-256). Decode + or length errors raise typed ``AdapterError`` (never a silent + fallback). With no KEK configured the store is DISABLED — honestly, + with a reason string — and the credential chain degrades to + server/keyless (C3-06). +- AAD: ``user_id.encode()`` — a key file copied under another user_id + fails ``InvalidTag`` at decrypt time (cross-user binding by + construction). +- Nonce: fresh ``os.urandom(12)`` per write; a GCM nonce must NEVER + repeat under the same KEK. +- File: ``keys/.json`` under the data root — ``{v, alg, + updated_at, entries}`` — written atomically (tmp + ``os.replace``) + with mode 0600. +- Crypto runs in ``asyncio.to_thread`` so the event loop is never + blocked by file I/O. +- Scope: ``OLLAMA_API_KEY`` only in v0.3 (D-109). + +KEK-loss posture (G-105, binding): + The KEK is NOT recoverable from anything in this repository or in + the running process. If the owner loses it, every per-user + encrypted key becomes unrecoverable — by design, that is the point + of envelope encryption. The honest degradation is: the key store + reports disabled, the resolution chain falls to server/keyless, + users re-enter their keys under a re-provisioned KEK, and the owner + MUST back up the KEK off-repo. Rotation tooling is out of v0.3 + scope (recorded). A changed KEK requires a process restart — the + secrets provider loads once at construction. + +Corruption is never a silent tier drop: ``InvalidTag`` (AAD tamper, +wrong KEK, garbage ciphertext) and malformed envelopes both surface as +typed errors (``KeyStoreError``), so the credential chain can report an +honest error state instead of quietly falling back. +""" + +import asyncio +import base64 +import os +import secrets +import threading +from datetime import UTC, datetime +from pathlib import Path + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from pydantic import BaseModel, Field, ValidationError + +from researchx.adapters.secrets import SecretsProvider +from researchx.adapters.types import AdapterError + +KEK_NAME = "RESEARCHX_KEK" +KEY_SCOPE = "OLLAMA_API_KEY" +_NONCE_BYTES = 12 +_KEY_BYTES = 32 + + +class KeyStoreError(AdapterError): + """Typed key-store failure: corrupt/tampered envelope, bad KEK shape. + + ``kind`` lets callers (routes, factory, tests) distinguish the + failure mode without parsing messages: + + - ``disabled`` — no usable KEK; the store refuses honestly. + - ``unreadable`` — the on-disk envelope is corrupt garbage. + - ``tampered`` — decrypt failed (InvalidTag: wrong user AAD, + wrong KEK, or bit-rot ciphertext). NEVER a + silent None — the tier must not silently drop. + - ``bad_kek`` — the configured KEK is malformed (decode/length). + """ + + def __init__(self, kind: str, message: str) -> None: + super().__init__(message) + self.kind = kind + + +class _KeyEntry(BaseModel): + nonce: str + ct: str + updated_at: str + + +class _KeyEnvelope(BaseModel): + v: int + alg: str = "AES-256-GCM" + updated_at: str + entries: dict[str, _KeyEntry] = Field(default_factory=dict) + + +def _now() -> str: + return datetime.now(UTC).isoformat() + + +def load_kek_bytes(secrets: SecretsProvider) -> bytes | None: + """Resolve ``RESEARCHX_KEK`` to raw 32 key bytes. + + Accepted encodings: urlsafe-base64 (with or without padding) or + hex. Anything that decodes but does not yield exactly 32 bytes, or + fails to decode at all, raises ``KeyStoreError(kind="bad_kek")`` + (the research-file's typed AdapterError contract). ``None`` is + returned only when the KEK is simply absent — the honest disabled + state, distinct from a broken one. + """ + raw = secrets.resolve(KEK_NAME) + if not raw: + return None + raw = raw.strip() + # Try hex first (unambiguous: hex keys are [0-9a-fA-F]{64}); then + # urlsafe b64 with flexible padding. + try: + if len(raw) == _KEY_BYTES * 2 and all(c in "0123456789abcdefABCDEF" for c in raw): + return bytes.fromhex(raw) + except ValueError as exc: # pragma: no cover — fromhex on checked chars can't fail + raise KeyStoreError("bad_kek", f"{KEK_NAME} is not valid hex") from exc + padded = raw + "=" * (-len(raw) % 4) + try: + decoded = base64.urlsafe_b64decode(padded.encode("ascii")) + except (ValueError, TypeError) as exc: + raise KeyStoreError( + "bad_kek", f"{KEK_NAME} is not valid urlsafe-b64 or hex" + ) from exc + if len(decoded) != _KEY_BYTES: + raise KeyStoreError( + "bad_kek", f"{KEK_NAME} must decode to exactly 32 bytes (AES-256), got {len(decoded)}" + ) + return decoded + + +def _encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _decode(token: str) -> bytes: + return base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)) + + +class KeyStore: + """Per-user encrypted key store (``keys/.json``). + + The store is ENABLED iff a valid KEK resolves at construction. + Every public method is async; file/crypto work runs in + ``asyncio.to_thread``. + """ + + def __init__(self, secrets: SecretsProvider, data_root: Path) -> None: + self._secrets = secrets + self._root = data_root / "keys" + self._lock = threading.Lock() + self._kek: bytes | None + self._disabled_reason: str | None = None + try: + self._kek = load_kek_bytes(secrets) + except KeyStoreError as exc: + # A malformed KEK is a hard misconfiguration: treat the + # store as disabled with the honest reason, never a crash + # and never a silent enable with the wrong key. + self._kek = None + self._disabled_reason = f"{KEK_NAME} is misconfigured: {exc}" + if self._kek is None and self._disabled_reason is None: + self._disabled_reason = ( + f"no {KEK_NAME} configured (env-file secrets 'keys' scope); " + "per-user model keys cannot be stored — the credential chain " + "falls to the server tier or keyless localhost" + ) + + # ------------------------------------------------------------ state + + @property + def enabled(self) -> bool: + return self._kek is not None + + @property + def disabled_reason(self) -> str | None: + return self._disabled_reason + + def _require_kek(self) -> bytes: + assert self._kek is not None # internal invariant; callers gate on enabled + return self._kek + + def _path(self, user_id: str) -> Path: + return self._root / f"{user_id}.json" + + # ------------------------------------------------------- sync workers + + def _read_envelope(self, user_id: str) -> _KeyEnvelope | None: + path = self._path(user_id) + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + try: + return _KeyEnvelope.model_validate_json(raw) + except ValidationError as exc: + raise KeyStoreError( + "unreadable", + f"key envelope for user {user_id} is corrupt ({exc.error_count()} errors)", + ) from exc + + def _set_key_locked(self, user_id: str, raw_key: str) -> None: + kek = self._require_kek() + nonce = os.urandom(_NONCE_BYTES) # fresh per write — NEVER reuse under the KEK + ct = AESGCM(kek).encrypt(nonce, raw_key.encode("utf-8"), user_id.encode("utf-8")) + envelope = _KeyEnvelope( + v=1, + alg="AES-256-GCM", + updated_at=_now(), + entries={KEY_SCOPE: _KeyEntry(nonce=_encode(nonce), ct=_encode(ct), updated_at=_now())}, + ) + path = self._path(user_id) + self._root.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp") + with open(tmp_path, "w", encoding="utf-8") as handle: + handle.write(envelope.model_dump_json(indent=2)) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, path) # atomic — a crash never leaves a torn file + + def _get_key_locked(self, user_id: str) -> str | None: + envelope = self._read_envelope(user_id) + if envelope is None: + return None + entry = envelope.entries.get(KEY_SCOPE) + if entry is None: + return None + kek = self._require_kek() + try: + plain = AESGCM(kek).decrypt( + _decode(entry.nonce), _decode(entry.ct), user_id.encode("utf-8") + ) + except InvalidTag as exc: + # AAD binding (user_id) failed, the KEK changed, or the + # ciphertext bit-rotted. This is a typed error, NEVER a + # silent None — a silent drop would let the tier fall to + # server/keyless without the user knowing their key is + # unreadable (C3-06). + raise KeyStoreError( + "tampered", + f"key envelope for user {user_id} failed authentication " + "(wrong user binding, changed KEK, or tampering)", + ) from exc + except (ValueError, TypeError) as exc: + raise KeyStoreError( + "unreadable", f"key envelope for user {user_id} has invalid encoding" + ) from exc + return plain.decode("utf-8") + + # ------------------------------------------------------------ API + + async def set_key(self, user_id: str, scope: str, raw_key: str) -> None: + """Encrypt and persist *raw_key* for *user_id* under *scope*. + + Rejected with a typed error when the store is disabled — an + honest refusal, never a silent no-op. + """ + if scope != KEY_SCOPE: + raise KeyStoreError("disabled", f"unsupported key scope: {scope} (v0.3: {KEY_SCOPE} only)") + if not self.enabled: + raise KeyStoreError("disabled", self._disabled_reason or "key store disabled") + + def _work() -> None: + with self._lock: + self._set_key_locked(user_id, raw_key) + + await asyncio.to_thread(_work) + + async def get_key(self, user_id: str) -> str | None: + """Decrypt and return the user's key, or None when none stored. + + Raises ``KeyStoreError`` on tamper/corruption (never a silent + None), and when the store is disabled with a stored envelope + that cannot be read. + """ + if not self.enabled: + # Without a KEK nothing can be read — but an envelope that + # EXISTS is surfaced as an error state (the user has a key + # we cannot serve), not silently None. + def _peek() -> bool: + return self._path(user_id).is_file() + + if await asyncio.to_thread(_peek): + raise KeyStoreError( + "disabled", + f"a key is stored for user {user_id} but the key store is disabled ({self._disabled_reason})", + ) + return None + + def _work() -> str | None: + with self._lock: + return self._get_key_locked(user_id) + + return await asyncio.to_thread(_work) + + async def masked_status(self, user_id: str) -> dict[str, object]: + """Masked-only status: ``{configured, masked}`` — NEVER the raw key. + + When the store is disabled the status says so honestly + (``disabled: True`` + reason). A tampered/unreadable envelope + is surfaced as an error field rather than ``configured: False`` + — never a silent tier drop. + """ + status: dict[str, object] = {"configured": False, "masked": None} + if not self.enabled: + status["disabled"] = True + status["reason"] = self._disabled_reason + + def _peek() -> bool: + return self._path(user_id).is_file() + + if await asyncio.to_thread(_peek): + # An unreadable stored key is an error state, not "not configured". + raise KeyStoreError( + "disabled", + f"a key is stored for user {user_id} but the key store is disabled ({self._disabled_reason})", + ) + return status + + def _work() -> dict[str, object]: + with self._lock: + envelope = self._read_envelope(user_id) + if envelope is None or envelope.entries.get(KEY_SCOPE) is None: + return {"configured": False, "masked": None, "disabled": False} + key = self._get_key_locked(user_id) # surfaces tamper as KeyStoreError + return { + "configured": True, + "masked": f"…{key[-4:]}" if key and len(key) >= 4 else "…", + "disabled": False, + } + + return await asyncio.to_thread(_work) + + +__all__ = ["KEK_NAME", "KEY_SCOPE", "KeyStore", "KeyStoreError", "load_kek_bytes"] \ No newline at end of file diff --git a/backend/src/researchx/auth/settings.py b/backend/src/researchx/auth/settings.py new file mode 100644 index 0000000..ad3b8a9 --- /dev/null +++ b/backend/src/researchx/auth/settings.py @@ -0,0 +1,104 @@ +"""Per-user settings persistence (T-2.3, REQ-72 — D-111). + +``settings/.json`` under the data root, written atomically +(tmp + ``os.replace``). Fresh users get defaults on read (GET never +writes). The ``UserSettings`` model carries the per-user surface the +Settings page binds to: planner/writer model names, methodology +defaults (resolved dial overrides per methodology — free-form dict in +v0.3, validated at use time), and the appearance slot. + +The storage directory is server-managed and display-only (D-111) — it +is NOT part of this payload; the config status route surfaces it. +""" + +import asyncio +import os +import secrets +import threading +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field, ValidationError + +from researchx.adapters.types import AdapterError + +# Default model names — the same defaults the live smoke tier uses +# (gpt-oss:20b on ollama.com). These are fallbacks only: the frontend +# Settings page always shows the current value. +DEFAULT_PLANNER_MODEL = "gpt-oss:20b" +DEFAULT_WRITER_MODEL = "gpt-oss:20b" + + +class UserSettings(BaseModel): + planner_model: str = DEFAULT_PLANNER_MODEL + writer_model: str = DEFAULT_WRITER_MODEL + methodology_defaults: dict[str, Any] = Field(default_factory=dict) + appearance: str = "system" + + +class SettingsStoreError(AdapterError): + """Typed settings-store failure (corrupt on-disk payload).""" + + +class SettingsStore: + """Per-user settings, one JSON file per user, atomic writes.""" + + def __init__(self, data_root: Path) -> None: + self._root = data_root / "settings" + self._lock = threading.Lock() + + def _path(self, user_id: str) -> Path: + return self._root / f"{user_id}.json" + + # ------------------------------------------------------------ sync + + def _read_sync(self, user_id: str) -> UserSettings: + path = self._path(user_id) + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError: + return UserSettings() # fresh user: defaults, no write on read + try: + return UserSettings.model_validate_json(raw) + except ValidationError as exc: + raise SettingsStoreError( + f"settings payload for user {user_id} is corrupt ({exc.error_count()} errors)" + ) from exc + + def _update_sync(self, user_id: str, partial: UserSettings) -> UserSettings: + current = self._read_sync(user_id) + merged = current.model_copy(update=partial.model_dump(exclude_unset=True)) + # Validate the merged payload BEFORE it touches disk: a null + # field slipped past model_copy (it skips validation) would + # poison the file — every later read would raise corrupt. + merged = UserSettings.model_validate(merged.model_dump()) + path = self._path(user_id) + self._root.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp") + with open(tmp_path, "w", encoding="utf-8") as handle: + handle.write(merged.model_dump_json(indent=2)) + handle.flush() + os.fsync(handle.fileno()) + tmp_path.replace(path) # atomic + return merged + + # ------------------------------------------------------------ async + + async def get(self, user_id: str) -> UserSettings: + return await asyncio.to_thread(self._read_sync, user_id) + + async def update(self, user_id: str, partial: UserSettings) -> UserSettings: + def _work() -> UserSettings: + with self._lock: + return self._update_sync(user_id, partial) + + return await asyncio.to_thread(_work) + + +__all__ = [ + "DEFAULT_PLANNER_MODEL", + "DEFAULT_WRITER_MODEL", + "SettingsStore", + "SettingsStoreError", + "UserSettings", +] \ No newline at end of file diff --git a/backend/src/researchx/engine/run_state.py b/backend/src/researchx/engine/run_state.py index ad1f82a..3efa57b 100644 --- a/backend/src/researchx/engine/run_state.py +++ b/backend/src/researchx/engine/run_state.py @@ -21,6 +21,10 @@ class RunState(BaseModel): # Owner of the run (REQ-71). None on pre-v0.3 state files — old # artifacts must deserialize unchanged (D-106: no migration). owner_id: str | None = None + # Which credential tier resolved for this run (REQ-74: user| + # server|keyless). None on pre-v0.3.2 state files — old artifacts + # deserialize unchanged (D-106: no migration). + credential_tier: str | None = None plan: Plan | None = None depth_cursor: int = 0 depth_target: int = 1 diff --git a/backend/tests/test_key_store_adversarial.py b/backend/tests/test_key_store_adversarial.py new file mode 100644 index 0000000..e3f5c97 --- /dev/null +++ b/backend/tests/test_key_store_adversarial.py @@ -0,0 +1,336 @@ +"""Key-store adversarial route suite (T-2.8 — security-engineer hat). + +Route-level adversarial coverage beyond the unit suite in +test_key_store_settings.py: corrupt/tampered envelopes surfacing +through the API (typed error, honest degraded tier, never a silent +drop and never a 500), raw-key non-leak across the FULL response +surface of a session that touches the key store, and the create_run +409 honesty on a corrupt envelope (never a wrong-key run, never a +silent tier fall). +""" + +import asyncio +import base64 +import json +import os +from pathlib import Path + +import httpx +import pytest + +from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter +from researchx.adapters.model_factory import ModelAdapterFactory +from researchx.adapters.secrets import EnvFileSecretsProvider +from researchx.adapters.types import ChatResult +from researchx.api.app import create_app +from researchx.auth.guard import CSRF_HEADER +from researchx.auth.key_store import KEK_NAME, KeyStore + +_PLAN = json.dumps( + {"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]} +) +_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n" + +_ADMIN_EMAIL = "admin@example.org" +_ADMIN_PASSWORD = "correct-horse-battery" +_SECOND_EMAIL = "second@example.org" +_SECOND_PASSWORD = "second-long-password" + +_HEADERS = {CSRF_HEADER: "1"} +_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode() +_USER_KEY = "adversarial-user-key-4242" + + +def _app(tmp_path: Path, secrets_values: dict[str, str]): + env_file = tmp_path / ".env.secrets" + env_file.write_text( + "".join(f"{k}={v}\n" for k, v in secrets_values.items()), encoding="utf-8" + ) + secrets = EnvFileSecretsProvider(paths=[env_file]) + return create_app( + storage_dir=tmp_path, + model_adapter=FakeModelAdapter( + canned=[ + ChatResult(content=_PLAN, model="fake-planner"), + ChatResult(content=_WRITER, model="fake-writer"), + ] + ), + search_adapter=FakeSearchAdapter(CORPUS), + auth_enabled=True, + data_dir=tmp_path, + secrets_provider=secrets, + model_factory=ModelAdapterFactory( + secrets, + KeyStore(secrets, tmp_path), + cloud_base_url="http://127.0.0.1:1/v1", # hermetic: unroutable + local_base_url="http://127.0.0.1:1/v1", + ), + ) + + +async def _login_admin(client: httpx.AsyncClient) -> None: + await client.post( + "/api/auth/register", + json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"}, + headers=_HEADERS, + ) + await client.post( + "/api/auth/login", + json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD}, + headers=_HEADERS, + ) + + +async def _store_key(client: httpx.AsyncClient, key: str) -> None: + response = await client.put( + "/api/config/settings/model-key", json={"key": key}, headers=_HEADERS + ) + assert response.status_code == 200 + assert response.json()["configured"] is True + + +def _corrupt_key_file(tmp_path: Path, user_id: str, content: str) -> None: + path = tmp_path / "keys" / f"{user_id}.json" + assert path.is_file(), "the key must be stored before corrupting it" + path.write_text(content, encoding="utf-8") + + +async def _current_user_id(client: httpx.AsyncClient) -> str: + me = await client.get("/api/auth/me") + return me.json()["user_id"] + + +async def _await_terminal_or_approval(client: httpx.AsyncClient, run_id: str) -> str: + for _ in range(300): + status = (await client.get(f"/api/runs/{run_id}")).json()["status"] + if status in ("done", "failed", "cancelled", "awaiting_approval"): + return status + await asyncio.sleep(0.01) + return status + + +# ------------------------------------------------- corrupt envelope at the API + + +async def test_config_status_degrades_honestly_on_garbage_envelope(tmp_path: Path) -> None: + """Garbage keys/.json -> config status 200 with a DEGRADED + tier (keyless/server) + the error surfaced in key_status.reason — + typed error surfacing, never a silent tier drop, never a 500.""" + app = _app(tmp_path, {KEK_NAME: _KEK}) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + await _login_admin(client) + await _store_key(client, _USER_KEY) + user_id = await _current_user_id(client) + _corrupt_key_file(tmp_path, user_id, "this is garbage, not json") + + config = await client.get("/api/config") + assert config.status_code == 200 # never a 500 + body = config.json() + assert body["resolved_tier"] in ("server", "keyless") # honest degradation + assert body["key_store"] == "enabled" + assert body["key_status"]["configured"] is False + assert body["key_status"]["reason"] is not None + assert "corrupt" in body["key_status"]["reason"].lower() + + +async def test_config_status_degrades_honestly_on_tampered_envelope(tmp_path: Path) -> None: + """A tampered envelope -> same honest degradation + 'failed + authentication' reason — never the decrypted key, never a silent + 'user' tier.""" + app = _app(tmp_path, {KEK_NAME: _KEK}) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + await _login_admin(client) + await _store_key(client, _USER_KEY) + user_id = await _current_user_id(client) + original = json.loads((tmp_path / "keys" / f"{user_id}.json").read_text()) + # Flip ciphertext bytes: the envelope stays valid JSON but + # decrypt must fail (InvalidTag -> typed 'tampered'). + ct = base64.urlsafe_b64decode(original["entries"]["OLLAMA_API_KEY"]["ct"] + "===") + flipped = bytes([ct[0] ^ 0xFF]) + ct[1:] + original["entries"]["OLLAMA_API_KEY"]["ct"] = ( + base64.urlsafe_b64encode(flipped).decode().rstrip("=") + ) + _corrupt_key_file(tmp_path, user_id, json.dumps(original)) + + config = await client.get("/api/config") + assert config.status_code == 200 + body = config.json() + assert body["resolved_tier"] in ("server", "keyless") + assert "authentication" in body["key_status"]["reason"].lower() + assert _USER_KEY not in config.text + + +async def test_create_run_corrupt_envelope_is_honest_409(tmp_path: Path) -> None: + """create_run with a corrupt stored envelope -> 409 with the typed + error — the run is NOT created (never a wrong-key run, never a + silent tier fall).""" + app = _app(tmp_path, {KEK_NAME: _KEK}) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + await _login_admin(client) + await _store_key(client, _USER_KEY) + user_id = await _current_user_id(client) + _corrupt_key_file(tmp_path, user_id, "{corrupt") + + created = await client.post( + "/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS + ) + assert created.status_code == 409 + detail = created.json()["detail"].lower() + assert "corrupt" in detail or "key" in detail + + +async def test_model_key_put_overwrites_after_corruption(tmp_path: Path) -> None: + """Recovery path: a user whose envelope corrupted can PUT a fresh + key (atomic overwrite) and return to the 'user' tier.""" + app = _app(tmp_path, {KEK_NAME: _KEK}) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + await _login_admin(client) + await _store_key(client, _USER_KEY) + user_id = await _current_user_id(client) + _corrupt_key_file(tmp_path, user_id, "garbage") + config = await client.get("/api/config") + assert config.json()["resolved_tier"] != "user" + + await _store_key(client, "fresh-key-value-9999") + config = await client.get("/api/config") + body = config.json() + assert body["resolved_tier"] == "user" + assert body["key_status"]["masked"].endswith("9999") + + +# ------------------------------------------------------------- leak scan + + +async def test_no_raw_key_in_full_session_response_surface(tmp_path: Path) -> None: + """Every response of a full session that stores, reads, and runs + with a user key — scanned for the raw key material (REQ-73).""" + app = _app(tmp_path, {KEK_NAME: _KEK}) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + await _login_admin(client) + put = await client.put( + "/api/config/settings/model-key", + json={"key": _USER_KEY}, + headers=_HEADERS, + ) + responses: list[httpx.Response] = [put] + for path in [ + "/api/config", + "/api/config/settings", + "/api/config/settings/model-key", + "/api/auth/me", + "/api/methodologies", + "/api/admin/users", + ]: + responses.append(await client.get(path)) + responses.append( + await client.put( + "/api/config/settings", json={"appearance": "dark"}, headers=_HEADERS + ) + ) + + # A run executing under the user tier must not leak the key in + # any run response either. + created = await client.post( + "/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS + ) + assert created.status_code == 201 + responses.append(created) + run_id = created.json()["id"] + status = await _await_terminal_or_approval(client, run_id) + responses.append(await client.get(f"/api/runs/{run_id}")) + if status == "awaiting_approval": + responses.append( + await client.post(f"/api/runs/{run_id}/approve", headers=_HEADERS) + ) + + for response in responses: + assert _USER_KEY not in response.text, "raw model key leaked in a response" + # And never on disk in plaintext. + for path in (tmp_path / "keys").iterdir(): + assert _USER_KEY not in path.read_text(encoding="utf-8") + + +# ---------------------------------------------------- AAD bind via the API + + +async def test_second_user_cannot_shadow_first_users_key(tmp_path: Path) -> None: + """Two users, each with their own key: the second user's tier is + 'user' via THEIR key, and the first user's key never affects the + second user's resolution (per-user AAD bind end to end).""" + app = _app(tmp_path, {KEK_NAME: _KEK}) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin: + await _login_admin(admin) + await _store_key(admin, "first-user-key-aaaa") + config = await admin.get("/api/config") + assert config.json()["resolved_tier"] == "user" + assert config.json()["key_status"]["masked"].endswith("aaaa") + await admin.post( + "/api/admin/users", + json={ + "email": _SECOND_EMAIL, + "password": _SECOND_PASSWORD, + "role": "user", + }, + headers=_HEADERS, + ) + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second: + await second.post( + "/api/auth/login", + json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD}, + headers=_HEADERS, + ) + # Before storing a key: keyless (no server key configured). + config = await second.get("/api/config") + assert config.json()["resolved_tier"] == "keyless" + assert "first-user-key" not in config.text + + await _store_key(second, "second-user-key-bbbb") + config = await second.get("/api/config") + assert config.json()["resolved_tier"] == "user" + assert config.json()["key_status"]["masked"].endswith("bbbb") + assert "aaaa" not in config.text + + +# ---------------------------------------------- settings store isolation + + +async def test_settings_corruption_is_typed_not_crashing(tmp_path: Path) -> None: + """A corrupt settings file surfaces a typed SettingsStoreError — + the store fails honestly rather than silently serving defaults + over a corrupt payload.""" + from researchx.auth.settings import SettingsStore, SettingsStoreError + + app = _app(tmp_path, {KEK_NAME: _KEK}) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + await _login_admin(client) + user_id = await _current_user_id(client) + settings_dir = tmp_path / "settings" + settings_dir.mkdir(parents=True, exist_ok=True) + (settings_dir / f"{user_id}.json").write_text("{{not json", encoding="utf-8") + + with pytest.raises(SettingsStoreError): + await SettingsStore(tmp_path).get(user_id) + + +def test_key_store_error_is_adapter_error() -> None: + """The typed key-store error IS an AdapterError — the factory and + routes can catch one family (C3-06 contract).""" + from researchx.adapters.types import AdapterError + from researchx.auth.key_store import KeyStoreError + + assert issubclass(KeyStoreError, AdapterError) \ No newline at end of file diff --git a/backend/tests/test_key_store_settings.py b/backend/tests/test_key_store_settings.py new file mode 100644 index 0000000..a2d7930 --- /dev/null +++ b/backend/tests/test_key_store_settings.py @@ -0,0 +1,474 @@ +"""Key store + settings store suite (T-2.2/T-2.3, REQ-72/73). + +The adversarial core of the per-user key store: KEK decode rules +(b64/hex/wrong-length/garbage), set/get round-trips, AAD binding +(a copied envelope under another user FAILS — never a wrong-key leak), +nonce uniqueness, masked-only status, disabled-honesty without a KEK, +0600 file permissions, and the settings defaults/update round-trip. + +The STEP-4 adversarial extension (corrupt-entry surfacing, concurrent +set_key atomicity, opt-in keyed live tier) lives at the bottom of this +file under the "T-2.8 adversarial extension" banner. +""" + +import asyncio +import base64 +import json +import os +from pathlib import Path + +import pytest + +from researchx.adapters.secrets import EnvFileSecretsProvider +from researchx.auth.key_store import KEK_NAME, KEY_SCOPE, KeyStore, KeyStoreError, load_kek_bytes +from researchx.auth.settings import SettingsStore, UserSettings + +_KEK_RAW = os.urandom(32) +_KEK_B64 = base64.urlsafe_b64encode(_KEK_RAW).decode() +_KEK_HEX = _KEK_RAW.hex() +_USER = "abcd2345" +_OTHER_USER = "efgh6789" +_RAW_KEY = "sk-live-key-abcd1234" + + +def _secrets_with(values: dict[str, str], tmp_path: Path) -> EnvFileSecretsProvider: + env_file = tmp_path / ".env.secrets" + env_file.write_text("".join(f"{k}={v}\n" for k, v in values.items()), encoding="utf-8") + return EnvFileSecretsProvider(paths=[env_file]) + + +def _enabled_store(tmp_path: Path) -> KeyStore: + return KeyStore(_secrets_with({KEK_NAME: _KEK_B64}, tmp_path), tmp_path) + + +# ---------------------------------------------------------------- KEK decode + + +def test_kek_decode_b64(tmp_path: Path) -> None: + secrets = _secrets_with({KEK_NAME: _KEK_B64}, tmp_path) + assert load_kek_bytes(secrets) == _KEK_RAW + + +def test_kek_decode_hex(tmp_path: Path) -> None: + secrets = _secrets_with({KEK_NAME: _KEK_HEX}, tmp_path) + assert load_kek_bytes(secrets) == _KEK_RAW + + +def test_kek_wrong_length_raises(tmp_path: Path) -> None: + short = base64.urlsafe_b64encode(b"only-16-bytes!!").decode() + with pytest.raises(KeyStoreError) as exc_info: + load_kek_bytes(_secrets_with({KEK_NAME: short}, tmp_path)) + assert "32 bytes" in str(exc_info.value) + assert exc_info.value.kind == "bad_kek" + + +def test_kek_garbage_raises(tmp_path: Path) -> None: + with pytest.raises(KeyStoreError) as exc_info: + load_kek_bytes(_secrets_with({KEK_NAME: "!!!not-base64!!!"}, tmp_path)) + assert exc_info.value.kind == "bad_kek" + + +def test_kek_absent_is_none_not_error(tmp_path: Path) -> None: + assert load_kek_bytes(_secrets_with({}, tmp_path)) is None + + +def test_bad_kek_disables_store_honestly(tmp_path: Path) -> None: + """A malformed KEK is not a crash and not a silent enable.""" + store = KeyStore(_secrets_with({KEK_NAME: "garbage-value"}, tmp_path), tmp_path) + assert store.enabled is False + assert store.disabled_reason is not None + assert "misconfigured" in store.disabled_reason + + +# ------------------------------------------------------------- round trips + + +async def test_set_get_round_trip(tmp_path: Path) -> None: + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, _RAW_KEY) + assert await store.get_key(_USER) == _RAW_KEY + + +async def test_get_without_set_is_none(tmp_path: Path) -> None: + store = _enabled_store(tmp_path) + assert await store.get_key(_USER) is None + + +async def test_set_overwrites_previous_key(tmp_path: Path) -> None: + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, "first-key-value") + await store.set_key(_USER, KEY_SCOPE, "second-key-value") + assert await store.get_key(_USER) == "second-key-value" + + +async def test_envelope_shape_on_disk(tmp_path: Path) -> None: + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, _RAW_KEY) + payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8")) + assert payload["v"] == 1 + assert payload["alg"] == "AES-256-GCM" + assert "updated_at" in payload + entry = payload["entries"][KEY_SCOPE] + assert set(entry) == {"nonce", "ct", "updated_at"} + # Raw key material must not appear anywhere in the file. + assert _RAW_KEY not in json.dumps(payload) + + +# -------------------------------------------------------------------- AAD + + +async def test_aad_tamper_copy_to_other_user_fails(tmp_path: Path) -> None: + """The AAD is user_id: a file copied under another user's id FAILS + decrypt with a typed error — never returns the other user's key.""" + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, _RAW_KEY) + + keys_dir = tmp_path / "keys" + (keys_dir / f"{_OTHER_USER}.json").write_text( + (keys_dir / f"{_USER}.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + + with pytest.raises(KeyStoreError) as exc_info: + await store.get_key(_OTHER_USER) + assert exc_info.value.kind == "tampered" + # Never the wrong user's key: + assert exc_info.value.args[0] != _RAW_KEY + + +async def test_aad_tamper_masked_status_also_fails(tmp_path: Path) -> None: + """Status must not silently report configured=False for a tampered + envelope — that would be a silent tier drop.""" + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, _RAW_KEY) + keys_dir = tmp_path / "keys" + (keys_dir / f"{_OTHER_USER}.json").write_text( + (keys_dir / f"{_USER}.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + with pytest.raises(KeyStoreError): + await store.masked_status(_OTHER_USER) + + +async def test_kek_change_surfaces_as_tamper(tmp_path: Path) -> None: + """A re-provisioned KEK cannot read old envelopes — typed error, + never silent None (documented loss posture, G-105).""" + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, _RAW_KEY) + + new_kek = base64.urlsafe_b64encode(os.urandom(32)).decode() + rotated = KeyStore(_secrets_with({KEK_NAME: new_kek}, tmp_path), tmp_path) + with pytest.raises(KeyStoreError) as exc_info: + await rotated.get_key(_USER) + assert exc_info.value.kind == "tampered" + + +# ------------------------------------------------------------------ nonces + + +async def test_nonce_unique_across_writes(tmp_path: Path) -> None: + store = _enabled_store(tmp_path) + nonces = [] + for i in range(3): + await store.set_key(_USER, KEY_SCOPE, f"key-number-{i}") + payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8")) + nonces.append(payload["entries"][KEY_SCOPE]["nonce"]) + assert len(set(nonces)) == 3 # a GCM nonce must NEVER repeat under a KEK + + +async def test_nonce_is_12_bytes(tmp_path: Path) -> None: + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, _RAW_KEY) + payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8")) + nonce_b64 = payload["entries"][KEY_SCOPE]["nonce"] + assert len(base64.urlsafe_b64decode(nonce_b64 + "===")) == 12 + + +# ------------------------------------------------------------ masked status + + +async def test_masked_status_never_returns_raw_key(tmp_path: Path) -> None: + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, _RAW_KEY) + status = await store.masked_status(_USER) + assert status["configured"] is True + assert status["masked"].endswith(_RAW_KEY[-4:]) + assert _RAW_KEY not in json.dumps(status) + assert status["masked"].startswith("…"[:1]) or status["masked"][0] in ("…", ".") + + +async def test_masked_status_unconfigured(tmp_path: Path) -> None: + store = _enabled_store(tmp_path) + status = await store.masked_status(_USER) + assert status["configured"] is False + assert status["masked"] is None + + +# -------------------------------------------------------- disabled honesty + + +async def test_disabled_store_set_rejected_honestly(tmp_path: Path) -> None: + store = KeyStore(_secrets_with({}, tmp_path), tmp_path) # no KEK + assert store.enabled is False + with pytest.raises(KeyStoreError) as exc_info: + await store.set_key(_USER, KEY_SCOPE, _RAW_KEY) + assert exc_info.value.kind == "disabled" + assert "RESEARCHX_KEK" in str(exc_info.value) + # And nothing was written to disk. + assert not (tmp_path / "keys" / f"{_USER}.json").exists() + + +async def test_disabled_store_get_returns_none(tmp_path: Path) -> None: + store = KeyStore(_secrets_with({}, tmp_path), tmp_path) + assert await store.get_key(_USER) is None + + +async def test_disabled_store_status_says_disabled(tmp_path: Path) -> None: + store = KeyStore(_secrets_with({}, tmp_path), tmp_path) + status = await store.masked_status(_USER) + assert status["disabled"] is True + assert "RESEARCHX_KEK" in str(status["reason"]) + assert status["configured"] is False + + +async def test_disabled_store_with_stored_key_surfaces_error(tmp_path: Path) -> None: + """The loss posture made visible: a KEK vanished after keys were + stored — reads must surface an error state, never silently None + (which would look like 'no key configured').""" + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, _RAW_KEY) + + gone = KeyStore(_secrets_with({}, tmp_path), tmp_path) + with pytest.raises(KeyStoreError) as exc_info: + await gone.get_key(_USER) + assert exc_info.value.kind == "disabled" + with pytest.raises(KeyStoreError): + await gone.masked_status(_USER) + + +async def test_unsupported_scope_rejected(tmp_path: Path) -> None: + store = _enabled_store(tmp_path) + with pytest.raises(KeyStoreError): + await store.set_key(_USER, "TAVILY_API_KEY", "should-not-work") + + +# ------------------------------------------------------------ file perms + + +async def test_key_file_perms_0600(tmp_path: Path) -> None: + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, _RAW_KEY) + mode = (tmp_path / "keys" / f"{_USER}.json").stat().st_mode & 0o777 + assert mode == 0o600 + + +def test_key_store_docstring_documents_loss_posture() -> None: + """G-105: the loss posture must be stated where the keys live.""" + import researchx.auth.key_store as key_store_module + + doc = key_store_module.__doc__ or "" + assert "KEK-loss" in doc or "unrecoverable" in doc + assert "back up the KEK" in doc + + +# ------------------------------------------------------------- settings + + +async def test_settings_defaults_for_fresh_user(tmp_path: Path) -> None: + store = SettingsStore(tmp_path) + settings = await store.get(_USER) + assert settings.planner_model + assert settings.writer_model + assert settings.methodology_defaults == {} + assert settings.appearance == "system" + # GET never writes: + assert not (tmp_path / "settings" / f"{_USER}.json").exists() + + +async def test_settings_update_round_trip(tmp_path: Path) -> None: + store = SettingsStore(tmp_path) + updated = await store.update(_USER, UserSettings(planner_model="m1", writer_model="m2")) + assert updated.planner_model == "m1" + assert updated.writer_model == "m2" + assert updated.appearance == "system" # untouched field keeps default + + reloaded = await store.get(_USER) + assert reloaded.planner_model == "m1" + assert reloaded.methodology_defaults == {} + + +async def test_settings_partial_update_preserves_other_fields(tmp_path: Path) -> None: + store = SettingsStore(tmp_path) + await store.update(_USER, UserSettings(planner_model="keep-planner", appearance="dark")) + updated = await store.update(_USER, UserSettings(appearance="light")) + assert updated.planner_model == "keep-planner" # survived the partial write + assert updated.appearance == "light" + + +async def test_settings_methodology_defaults_round_trip(tmp_path: Path) -> None: + store = SettingsStore(tmp_path) + payload = UserSettings(methodology_defaults={"rapid-answer": {"depth": 2}}) + await store.update(_USER, payload) + reloaded = await store.get(_USER) + assert reloaded.methodology_defaults == {"rapid-answer": {"depth": 2}} + + +async def test_settings_update_validates_merged_payload_before_write(tmp_path: Path) -> None: + """P2-verify P0 regression: the store never writes a payload it + cannot read back — a null field that slipped past model_copy (which + skips validation) is refused before touching disk.""" + store = SettingsStore(tmp_path) + await store.update(_USER, UserSettings(planner_model="keep-me")) + poisoned = await store.get(_USER) + poisoned = poisoned.model_copy(update={"planner_model": None}) + with pytest.raises(ValueError): + await store.update(_USER, poisoned) + reloaded = await store.get(_USER) + assert reloaded.planner_model == "keep-me" # on-disk payload untouched + + +async def test_settings_isolated_per_user(tmp_path: Path) -> None: + store = SettingsStore(tmp_path) + await store.update(_USER, UserSettings(appearance="dark")) + other = await store.get(_OTHER_USER) + assert other.appearance == "system" # fresh defaults, no bleed + assert not (tmp_path / "settings" / f"{_OTHER_USER}.json").exists() + + +async def test_settings_corrupt_payload_surfaces_typed_error(tmp_path: Path) -> None: + store = SettingsStore(tmp_path) + settings_dir = tmp_path / "settings" + settings_dir.mkdir(parents=True, exist_ok=True) + (settings_dir / f"{_USER}.json").write_text("{not json", encoding="utf-8") + from researchx.auth.settings import SettingsStoreError + + with pytest.raises((SettingsStoreError, ValueError)): + await store.get(_USER) + + +# ================================================== T-2.8 adversarial extension +# (security-engineer hat — corrupt-entry surfacing, concurrent-set +# atomicity, and the opt-in keyed live tier. See STEP 4.) + + +async def test_corrupt_envelope_surfaces_typed_error_never_silent_drop(tmp_path: Path) -> None: + """Garbage in keys/.json → KeyStoreError('unreadable'), never + a silent None (which would silently drop the user's tier).""" + store = _enabled_store(tmp_path) + keys_dir = tmp_path / "keys" + keys_dir.mkdir(parents=True, exist_ok=True) + (keys_dir / f"{_USER}.json").write_text("this is not json at all", encoding="utf-8") + with pytest.raises(KeyStoreError) as exc_info: + await store.get_key(_USER) + assert exc_info.value.kind == "unreadable" + + +async def test_corrupt_entry_fields_surface_typed_error(tmp_path: Path) -> None: + """Structurally-valid JSON with missing/invalid fields — typed error.""" + store = _enabled_store(tmp_path) + keys_dir = tmp_path / "keys" + keys_dir.mkdir(parents=True, exist_ok=True) + (keys_dir / f"{_USER}.json").write_text( + json.dumps({"v": 1, "alg": "AES-256-GCM", "entries": {"OLLAMA_API_KEY": {"nonce": "xx"}}}), + encoding="utf-8", + ) + with pytest.raises(KeyStoreError): + await store.get_key(_USER) + + +async def test_bit_rot_ciphertext_surfaces_tamper(tmp_path: Path) -> None: + """Flipping ciphertext bytes → InvalidTag → typed 'tampered'.""" + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, _RAW_KEY) + path = tmp_path / "keys" / f"{_USER}.json" + payload = json.loads(path.read_text(encoding="utf-8")) + ct = base64.urlsafe_b64decode(payload["entries"][KEY_SCOPE]["ct"] + "===") + flipped = bytes([ct[0] ^ 0xFF]) + ct[1:] + payload["entries"][KEY_SCOPE]["ct"] = base64.urlsafe_b64encode(flipped).decode().rstrip("=") + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(KeyStoreError) as exc_info: + await store.get_key(_USER) + assert exc_info.value.kind == "tampered" + + +async def test_concurrent_set_key_never_tears_the_file(tmp_path: Path) -> None: + """Concurrent writes to the same user: every reader (including a + second KeyStore handle over the same root) sees a valid envelope — + the tmp+replace idiom leaves no torn files.""" + store = _enabled_store(tmp_path) + await asyncio.gather( + *(store.set_key(_USER, KEY_SCOPE, f"concurrent-key-{i}") for i in range(16)) + ) + # The final file is one of the written keys, decryptable, well-formed. + final = await store.get_key(_USER) + assert final is not None + assert final.startswith("concurrent-key-") + + # A second handle (fresh process view) reads the same valid file. + second = KeyStore(_secrets_with({KEK_NAME: _KEK_B64}, tmp_path), tmp_path) + again = await second.get_key(_USER) + assert again == final + payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8")) + assert payload["v"] == 1 # full envelope, never a partial write + # No tmp strays left behind. + assert [p.name for p in (tmp_path / "keys").iterdir() if p.name.endswith(".tmp")] == [] + + +async def test_set_key_atomic_under_concurrent_reads(tmp_path: Path) -> None: + """Reads racing a write never observe a torn file.""" + store = _enabled_store(tmp_path) + await store.set_key(_USER, KEY_SCOPE, "initial-key") + + async def _reader() -> str | None: + for _ in range(20): + try: + key = await store.get_key(_USER) + if key is None: + raise AssertionError("envelope vanished mid-race") + except KeyStoreError as exc: + raise AssertionError(f"torn read surfaced: {exc}") from exc + return None + + async def _writer() -> None: + for i in range(10): + await store.set_key(_USER, KEY_SCOPE, f"racing-key-{i}") + + await asyncio.gather(_reader(), _writer()) + assert (await store.get_key(_USER)).startswith("racing-key-") + + +def test_keyed_live_tier_marker_registered() -> None: + """The opt-in keyed live tier is marked `live` and skipped by + default (same pattern as test_live_smoke.py): the marker must be + registered in the pytest config so the opt-in test below skips + cleanly in zero-key CI.""" + import tomllib + + config = tomllib.loads((Path(__file__).resolve().parents[1] / "pyproject.toml").read_text()) + markers = config["tool"]["pytest"]["ini_options"]["markers"] + assert any(str(m).startswith("live:") for m in markers) + + +# ------------------------------------------------------- opt-in live tier + + +@pytest.mark.live +async def test_live_keyed_tier_end_to_end(tmp_path: Path) -> None: + """Opt-in keyed tier (skipped by default, like test_live_smoke): + with a real RESEARCHX_KEK + OLLAMA_API_KEY in the secrets scope, a + keyed user gets real inference through the REQ-74 chain.""" + secrets_paths = [ + Path.cwd() / ".ciagent" / ".env.secrets", + Path.cwd().parents[1] / ".ciagent" / ".env.secrets", + ] + env = EnvFileSecretsProvider(paths=secrets_paths) + kek = env.resolve(KEK_NAME) + model_key = env.resolve("OLLAMA_API_KEY") + if not kek or not model_key: + pytest.skip("RESEARCHX_KEK/OLLAMA_API_KEY not present — live tier is opt-in") + + store = KeyStore(env, tmp_path) + assert store.enabled, "with a configured KEK the store must be enabled" + await store.set_key("live0001", KEY_SCOPE, model_key) + resolved = await store.get_key("live0001") + assert resolved == model_key \ No newline at end of file diff --git a/backend/tests/test_main_entrypoint.py b/backend/tests/test_main_entrypoint.py new file mode 100644 index 0000000..9ff676e --- /dev/null +++ b/backend/tests/test_main_entrypoint.py @@ -0,0 +1,312 @@ +"""Production entrypoint suite (T-2.7 — P-3 backend closure). + +The live app built by ``researchx.api.main`` must be structurally +incapable of serving fakes: + +- The per-run model path is the ModelAdapterFactory — a run executes + against an ``OllamaModelAdapter`` (resolved per user), never + ``FakeModelAdapter``. Pinned by asserting the factory resolves the + adapter and that the app instance holds no fake in the model slot. +- ``auth_enabled=True`` hardwired: every business route 401s without a + session; there is no parameter or env that turns auth off. +- ``cookie_secure=True``: the login cookie carries Secure (behind TLS). +- The data root + secrets file come from env vars (RESEARCHX_DATA_DIR, + RESEARCHX_SECRETS_FILE) — the test seam is the natural runtime + config, parameterized not env-gated. +""" + +import base64 +import json +import os +from pathlib import Path + +import httpx +import pytest + +from researchx.adapters.fakes import FakeModelAdapter +from researchx.auth.guard import CSRF_HEADER + +_HEADERS = {CSRF_HEADER: "1"} +_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode() + +_ADMIN_EMAIL = "admin@example.org" +_ADMIN_PASSWORD = "correct-horse-battery" + + +def _runtime_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, secrets: str | None, data_dir: Path +) -> None: + """Point the production entry at test-owned locations. + + This is the parameterization seam: main.py reads the same env vars + the container runtime passes (RESEARCHX_SECRETS_FILE via --env-file + conventions, RESEARCHX_DATA_DIR for the volume) — no test-only + gates exist in the module. + """ + monkeypatch.setenv("RESEARCHX_DATA_DIR", str(data_dir)) + if secrets is None: + monkeypatch.delenv("RESEARCHX_SECRETS_FILE", raising=False) + else: + env_file = tmp_path / "env.secrets" + env_file.write_text(secrets, encoding="utf-8") + monkeypatch.setenv("RESEARCHX_SECRETS_FILE", str(env_file)) + + +def _live_secrets(kek: bool = True, server_key: bool = True) -> str: + lines = [] + if kek: + lines.append(f"RESEARCHX_KEK={_KEK}") + if server_key: + lines.append("OLLAMA_API_KEY=server-tier-key-value") + return "\n".join(lines) + "\n" + + +async def _register_admin(client: httpx.AsyncClient) -> None: + await client.post( + "/api/auth/register", + json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"}, + headers=_HEADERS, + ) + + +async def _login_with_secure_cookie(client: httpx.AsyncClient) -> str: + """Login and return the session cookie header value. + + The production cookie carries Secure (hardwired, E-4); httpx's + cookie jar — like a browser over plain http — will not replay a + Secure cookie to a non-TLS origin. The tests therefore forward the + cookie explicitly: the ASGI transport is the TLS proxy's interior + leg, where the Secure flag does not apply. + """ + login = await client.post( + "/api/auth/login", + json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD}, + headers=_HEADERS, + ) + assert login.status_code == 200 + return login.headers["set-cookie"].split(";")[0] + + +def _auth_headers(cookie: str) -> dict[str, str]: + return {**_HEADERS, "cookie": cookie} + + +# --------------------------------------------------------------- structure + + +async def test_main_factory_resolves_real_adapters( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The P-3 backend closure: main's factory wiring resolves a real + OllamaModelAdapter per user — the production app can never serve + FakeModelAdapter (no fixed model adapter is even constructed when + a factory is wired; pinned structurally in app.py).""" + from researchx.api import main as main_module + + _runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=tmp_path / "data") + main_module.build_app() # must import + build cleanly + + from datetime import UTC, datetime + + from researchx.adapters.model_factory import ModelAdapterFactory + from researchx.adapters.ollama import OllamaModelAdapter + from researchx.adapters.secrets import EnvFileSecretsProvider + from researchx.auth.key_store import KeyStore + from researchx.auth.models import UserPublic + from researchx.auth.settings import UserSettings + + secrets_paths = main_module._secrets_paths() + assert secrets_paths, "the runtime env file must be discoverable" + secrets = EnvFileSecretsProvider(paths=secrets_paths) + factory = ModelAdapterFactory(secrets, KeyStore(secrets, tmp_path / "data")) + user = UserPublic( + user_id="abcd2345", + email="u@example.org", + display_name="U", + role="user", + active=True, + created_at=datetime.now(UTC), + ) + adapter, tier = await factory.resolve(user, UserSettings()) + assert isinstance(adapter, OllamaModelAdapter) + assert not isinstance(adapter, FakeModelAdapter) + assert tier in ("user", "server", "keyless") + + +# --------------------------------------------------------------- behavior + + +async def test_main_app_auth_hardwired(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """No env, no parameter: every business route 401s without a + session (REQ-85 — there is no auth_enabled=False path in main).""" + from researchx.api import main as main_module + + _runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=tmp_path / "data") + app = main_module.build_app() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + for path in [ + "/api/config", + "/api/config/settings", + "/api/config/settings/model-key", + "/api/methodologies", + "/api/runs/abcd2345", + ]: + response = await client.get(path) + assert response.status_code == 401, f"{path} leaked without auth" + # POST-only surface rejects unauthenticated POSTs identically. + assert (await client.post("/api/runs", json={}, headers=_HEADERS)).status_code == 401 + # The public surface stays public (D-103). + assert (await client.get("/healthz")).status_code == 200 + + +async def test_main_app_cookie_secure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """cookie_secure=True hardwired (E-4): the session cookie carries + Secure because the public surface is behind the TLS proxy.""" + from researchx.api import main as main_module + + data_dir = tmp_path / "data" + _runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir) + app = main_module.build_app() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + await _register_admin(client) + login = await client.post( + "/api/auth/login", + json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD}, + headers=_HEADERS, + ) + assert login.status_code == 200 + set_cookie = login.headers["set-cookie"].lower() + assert "secure" in set_cookie # hardwired behind TLS + + +async def test_main_app_config_resolves_tier_with_runtime_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """End to end through the production wiring: register + login + + GET /api/config shows the resolved tier and key-store state from + the runtime env file (KEK + server key -> server tier for a + keyless user, key_store enabled).""" + from researchx.api import main as main_module + + data_dir = tmp_path / "data" + _runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir) + app = main_module.build_app() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + await _register_admin(client) + cookie = await _login_with_secure_cookie(client) + config = await client.get("/api/config", headers=_auth_headers(cookie)) + assert config.status_code == 200 + body = config.json() + assert body["resolved_tier"] == "server" # server key present, no user key + assert body["key_store"] == "enabled" + assert body["model_adapter"] == "OllamaModelAdapter" # never the fake + + +async def test_main_app_keyless_without_keys( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """No KEK, no server key: key_store disabled, tier keyless — the + honest zero-key posture through the production wiring.""" + from researchx.api import main as main_module + + data_dir = tmp_path / "data" + _runtime_env(tmp_path, monkeypatch, secrets="", data_dir=data_dir) + app = main_module.build_app() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + await _register_admin(client) + cookie = await _login_with_secure_cookie(client) + config = await client.get("/api/config", headers=_auth_headers(cookie)) + body = config.json() + assert body["resolved_tier"] == "keyless" + assert body["key_store"] == "disabled" + assert body["key_status"]["disabled"] is True + + +async def test_main_app_key_store_round_trip_with_kek( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """With a runtime KEK the production app accepts a user key and + resolves tier 'user' afterwards (REQ-73/74 chain end to end).""" + from researchx.api import main as main_module + + data_dir = tmp_path / "data" + _runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir) + app = main_module.build_app() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + await _register_admin(client) + cookie = await _login_with_secure_cookie(client) + put = await client.put( + "/api/config/settings/model-key", + json={"key": "user-tier-key-7777"}, + headers=_auth_headers(cookie), + ) + assert put.status_code == 200 + assert put.json()["configured"] is True + + config = await client.get("/api/config", headers=_auth_headers(cookie)) + body = config.json() + assert body["resolved_tier"] == "user" + assert body["key_status"]["masked"].endswith("7777") + + # The key file lands under the DATA ROOT (not cwd). + me = await client.get("/api/auth/me", headers=_auth_headers(cookie)) + user_id = me.json()["user_id"] + key_file = data_dir / "keys" / f"{user_id}.json" + assert key_file.is_file() + payload = json.loads(key_file.read_text(encoding="utf-8")) + assert "user-tier-key-7777" not in json.dumps(payload) # encrypted at rest + + +async def test_main_app_storage_is_data_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The config status storage_dir is the data-root string (D-107) — + never cwd-dependent.""" + from researchx.api import main as main_module + + data_dir = tmp_path / "data-root" + _runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir) + app = main_module.build_app() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as client: + await _register_admin(client) + cookie = await _login_with_secure_cookie(client) + config = await client.get("/api/config", headers=_auth_headers(cookie)) + assert config.json()["storage_dir"] == str(data_dir) + + +def test_main_module_has_no_auth_disable_surface() -> None: + """The main module exposes no auth_enabled parameter and reads no + env var that could disable auth (REQ-85 + C3-16).""" + import inspect + + from researchx.api import main as main_module + + signature = inspect.signature(main_module.build_app) + assert "auth_enabled" not in signature.parameters + assert "cookie_secure" not in signature.parameters + source = inspect.getsource(main_module) + assert "RESEARCHX_AUTH" not in source + assert "auth_enabled=False" not in source + # build_app passes auth_enabled=True exactly once (hardcoded). + build_source = inspect.getsource(main_module.build_app) + assert build_source.count("auth_enabled=") == 1 + assert "auth_enabled=True" in build_source + assert "cookie_secure=True" in build_source \ No newline at end of file diff --git a/backend/tests/test_settings_routes_factory.py b/backend/tests/test_settings_routes_factory.py new file mode 100644 index 0000000..359ddaa --- /dev/null +++ b/backend/tests/test_settings_routes_factory.py @@ -0,0 +1,651 @@ +"""Settings + model-key routes + factory + config-status suite (STEP 2). + +Covers T-2.4 (settings/key routes), T-2.5 (per-run model factory +chain: user → server → keyless), and T-2.6 (config status rework — +P-1 closure: factory-resolved probe for the CURRENT user, zero runs +required, no cross-user leak). + +Run under the ``auth_enabled=True`` harness (two users prove per-user +isolation) plus direct factory tests against fake key stores. +""" + +import base64 +import json +import os +from pathlib import Path + +import httpx +import pytest + +from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter +from researchx.adapters.model_factory import ModelAdapterFactory +from researchx.adapters.secrets import EnvFileSecretsProvider +from researchx.adapters.types import ChatResult +from researchx.api.app import create_app +from researchx.auth.guard import CSRF_HEADER +from researchx.auth.key_store import KEK_NAME, KeyStore +from researchx.auth.models import UserPublic +from researchx.auth.settings import UserSettings + +_PLAN = json.dumps( + {"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]} +) +_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n" + +_ADMIN_EMAIL = "admin@example.org" +_ADMIN_PASSWORD = "correct-horse-battery" +_SECOND_EMAIL = "second@example.org" +_SECOND_PASSWORD = "another-long-password" + +_HEADERS = {CSRF_HEADER: "1"} + +_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode() + +_FAKE_KEY = "sk-user-tier-key-9999" + + +def _env_file(tmp_path: Path, values: dict[str, str]) -> Path: + env_file = tmp_path / ".env.secrets" + env_file.write_text("".join(f"{k}={v}\n" for k, v in values.items()), encoding="utf-8") + return env_file + + +def _user(user_id: str = "abcd2345") -> UserPublic: + from datetime import UTC, datetime + + return UserPublic( + user_id=user_id, + email="user@example.org", + display_name="User", + role="user", + active=True, + created_at=datetime.now(UTC), + ) + + +# ------------------------------------------------------------- harness + + +def _app( + tmp_path: Path, + *, + secrets_values: dict[str, str] | None = None, + with_factory: bool = True, +): + """create_app under auth_enabled=True with live per-user stores. + + The model adapter stays the zero-key fake (the fake's chat is what + runs execute against); the FACTORY is wired so the tier chain and + config-status rework are exercised end to end. The factory's bases + point at an unroutable host so config-status probes fail fast and + hermetically (no outbound HTTP from the unit tier — the probe + contract has its own monkeypatched tests below). + """ + secrets = EnvFileSecretsProvider( + paths=[_env_file(tmp_path, secrets_values or {})] + ) + return create_app( + storage_dir=tmp_path, + model_adapter=FakeModelAdapter( + canned=[ + ChatResult(content=_PLAN, model="fake-planner"), + ChatResult(content=_WRITER, model="fake-writer"), + ] + ), + search_adapter=FakeSearchAdapter(CORPUS), + auth_enabled=True, + data_dir=tmp_path, + secrets_provider=secrets, + model_factory=ModelAdapterFactory( + secrets, + KeyStore(secrets, tmp_path), + cloud_base_url="http://127.0.0.1:1/v1", + local_base_url="http://127.0.0.1:1/v1", + ) + if with_factory + else None, + ) + + +async def _client(tmp_path: Path, **kwargs) -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=_app(tmp_path, **kwargs)), + base_url="http://testserver", + ) + + +async def _register_and_login(client: httpx.AsyncClient) -> dict[str, str]: + await client.post( + "/api/auth/register", + json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"}, + headers=_HEADERS, + ) + login = await client.post( + "/api/auth/login", json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD}, headers=_HEADERS + ) + return login.json() + + +# --------------------------------------------------- settings CRUD routes + + +async def test_settings_get_returns_defaults(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + me = await _register_and_login(client) + response = await client.get("/api/config/settings") + assert response.status_code == 200 + body = response.json() + assert body["planner_model"] + assert body["writer_model"] + assert body["methodology_defaults"] == {} + assert body["appearance"] == "system" + # never leaks the user's id/email into the settings payload + assert "user_id" not in body + assert me["email"] not in json.dumps(body) + + +async def test_settings_put_round_trip(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + await _register_and_login(client) + put = await client.put( + "/api/config/settings", + json={"planner_model": "qwen-max", "appearance": "dark"}, + headers=_HEADERS, + ) + assert put.status_code == 200 + assert put.json()["planner_model"] == "qwen-max" + assert put.json()["appearance"] == "dark" + # writer_model untouched -> default preserved + assert put.json()["writer_model"] + + got = await client.get("/api/config/settings") + assert got.json()["planner_model"] == "qwen-max" + assert got.json()["appearance"] == "dark" + + +async def test_settings_put_partial_preserves(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + await _register_and_login(client) + await client.put( + "/api/config/settings", json={"planner_model": "p-one"}, headers=_HEADERS + ) + put = await client.put( + "/api/config/settings", json={"writer_model": "w-two"}, headers=_HEADERS + ) + assert put.json()["planner_model"] == "p-one" + assert put.json()["writer_model"] == "w-two" + + +async def test_settings_put_validates_body(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + await _register_and_login(client) + response = await client.put( + "/api/config/settings", json={"planner_model": ""}, headers=_HEADERS + ) + assert response.status_code == 422 + + +async def test_settings_put_requires_csrf(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + await _register_and_login(client) + response = await client.put("/api/config/settings", json={"appearance": "dark"}) + assert response.status_code == 403 + + +async def test_settings_put_explicit_null_treated_as_absent(tmp_path: Path) -> None: + """P2-verify P0 regression: an explicit null field must be treated + as absent (partial-merge semantics) — never 200-then-poison the + on-disk payload into a corrupt file that 500s every later read.""" + async with await _client(tmp_path) as client: + await _register_and_login(client) + put = await client.put( + "/api/config/settings", json={"planner_model": None}, headers=_HEADERS + ) + assert put.status_code == 200 + body = put.json() + assert body["planner_model"] # default preserved, not null + + # Every subsequent read + write + run creation stays healthy. + got = await client.get("/api/config/settings") + assert got.status_code == 200 + assert got.json()["planner_model"] + again = await client.put( + "/api/config/settings", json={"planner_model": "qwen-max"}, headers=_HEADERS + ) + assert again.status_code == 200 + assert again.json()["planner_model"] == "qwen-max" + created = await client.post( + "/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS + ) + assert created.status_code == 201 + + +async def test_settings_isolated_per_user(tmp_path: Path) -> None: + app = _app(tmp_path) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin: + await _register_and_login(admin) + await admin.post( + "/api/admin/users", + json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"}, + headers=_HEADERS, + ) + await admin.put( + "/api/config/settings", json={"appearance": "dark"}, headers=_HEADERS + ) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second: + await second.post( + "/api/auth/login", + json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD}, + headers=_HEADERS, + ) + got = await second.get("/api/config/settings") + assert got.json()["appearance"] == "system" # no bleed + + +# --------------------------------------------------- model-key routes + + +async def test_model_key_put_masked_round_trip(tmp_path: Path) -> None: + """PUT the raw key -> 200 with masked status only; the raw key + NEVER appears in any response (REQ-73).""" + async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client: + me = await _register_and_login(client) + put = await client.put( + "/api/config/settings/model-key", + json={"key": _FAKE_KEY}, + headers=_HEADERS, + ) + assert put.status_code == 200 + body = put.json() + assert body["configured"] is True + assert body["masked"] is not None + assert body["masked"].endswith(_FAKE_KEY[-4:]) + assert _FAKE_KEY not in put.text + + status = await client.get("/api/config/settings/model-key") + assert status.json()["configured"] is True + assert _FAKE_KEY not in status.text + assert me["user_id"] not in status.text + + +async def test_model_key_put_disabled_without_kek_honest_409(tmp_path: Path) -> None: + """No KEK -> 409 with the honest {key_store: disabled} payload — + never a silent acceptance.""" + async with await _client(tmp_path) as client: # no KEK in secrets + await _register_and_login(client) + put = await client.put( + "/api/config/settings/model-key", + json={"key": "some-key-value"}, + headers=_HEADERS, + ) + assert put.status_code == 409 + detail = put.json()["detail"] + assert detail["key_store"] == "disabled" + assert "RESEARCHX_KEK" in detail["reason"] + # Nothing was written to disk. + user_dir = tmp_path / "keys" + assert not user_dir.exists() or not any(user_dir.iterdir()) + + +async def test_model_key_get_status_disabled_says_so(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + await _register_and_login(client) + status = await client.get("/api/config/settings/model-key") + assert status.status_code == 200 + body = status.json() + assert body["disabled"] is True + assert "RESEARCHX_KEK" in body["reason"] + assert body["configured"] is False + + +async def test_model_key_put_requires_csrf(tmp_path: Path) -> None: + async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client: + await _register_and_login(client) + response = await client.put( + "/api/config/settings/model-key", json={"key": "no-header-key"} + ) + assert response.status_code == 403 + + +async def test_model_key_never_in_config_or_settings_responses(tmp_path: Path) -> None: + """The raw key must not surface in ANY authenticated response.""" + async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client: + await _register_and_login(client) + await client.put( + "/api/config/settings/model-key", json={"key": _FAKE_KEY}, headers=_HEADERS + ) + for path in [ + "/api/config", + "/api/config/settings", + "/api/config/settings/model-key", + "/api/auth/me", + "/api/methodologies", + ]: + response = await client.get(path) + assert _FAKE_KEY not in response.text, f"raw key leaked via {path}" + + +# --------------------------------------------------------- factory chain + + +class _FakeKeyStore: + """Test double for KeyStore: enabled with a known key or disabled.""" + + def __init__(self, key: str | None = None, enabled: bool = True) -> None: + self._key = key + self._enabled_flag = enabled + + @property + def enabled(self) -> bool: + return self._enabled_flag + + async def get_key(self, user_id: str) -> str | None: + del user_id + return self._key + + async def masked_status(self, user_id: str) -> dict[str, object]: + del user_id + return { + "configured": self._key is not None, + "masked": f"…{self._key[-4:]}" if self._key else None, + "disabled": not self._enabled_flag, + } + + +def _factory(tmp_path: Path, secrets_values: dict[str, str], key_store: object) -> ModelAdapterFactory: + secrets = EnvFileSecretsProvider(paths=[_env_file(tmp_path, secrets_values)]) + return ModelAdapterFactory( + secrets, + key_store, # type: ignore[arg-type] + cloud_base_url="http://127.0.0.1:1/v1", # unroutable: hermetic unit tier + local_base_url="http://127.0.0.1:1/v1", + ) + + +async def test_factory_user_tier_when_key_present(tmp_path: Path) -> None: + factory = _factory( + tmp_path, + {"OLLAMA_API_KEY": "server-key-value"}, + _FakeKeyStore(key=_FAKE_KEY), + ) + adapter, tier = await factory.resolve(_user(), UserSettings()) + assert tier == "user" + assert adapter.roles().planner == UserSettings().planner_model + + +async def test_factory_server_tier_when_no_user_key(tmp_path: Path) -> None: + factory = _factory( + tmp_path, + {"OLLAMA_API_KEY": "server-key-value"}, + _FakeKeyStore(key=None), + ) + _adapter, tier = await factory.resolve(_user(), UserSettings()) + assert tier == "server" + + +async def test_factory_keyless_when_neither(tmp_path: Path) -> None: + factory = _factory(tmp_path, {}, _FakeKeyStore(key=None)) + _adapter, tier = await factory.resolve(_user(), UserSettings()) + assert tier == "keyless" + + +async def test_factory_keyless_when_store_disabled(tmp_path: Path) -> None: + factory = _factory( + tmp_path, + {"OLLAMA_API_KEY": "server-key-value"}, + _FakeKeyStore(key=None, enabled=False), + ) + _adapter, tier = await factory.resolve(_user(), UserSettings()) + assert tier == "server" # chain proceeds to server when key store disabled + + +async def test_factory_respects_user_settings_models(tmp_path: Path) -> None: + factory = _factory(tmp_path, {}, _FakeKeyStore(key=None)) + settings = UserSettings(planner_model="my-planner", writer_model="my-writer") + adapter, tier = await factory.resolve(_user(), settings) + assert tier == "keyless" + assert adapter.roles().planner == "my-planner" + assert adapter.roles().writer == "my-writer" + + +async def test_factory_never_caches_adapters(tmp_path: Path) -> None: + factory = _factory(tmp_path, {}, _FakeKeyStore(key=None)) + first, _ = await factory.resolve(_user(), UserSettings()) + second, _ = await factory.resolve(_user(), UserSettings()) + assert first is not second # one instance per run (P-2 closure) + + +async def test_factory_user_key_precedence_over_server_secrets( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The resolved user key must be the Authorization the adapter sends + — not the server env-file key.""" + seen_headers: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen_headers.update(dict(request.headers)) + return httpx.Response( + 200, json={"choices": [{"message": {"content": '{"ok": true}'}}]} + ) + + transport = httpx.MockTransport(handler) + original = httpx.AsyncClient + monkeypatch.setattr( + httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs) + ) + + factory = _factory( + tmp_path, + {"OLLAMA_API_KEY": "server-key-value"}, + _FakeKeyStore(key=_FAKE_KEY), + ) + adapter, tier = await factory.resolve(_user(), UserSettings()) + assert tier == "user" + await adapter.chat( + [__import__("researchx.adapters.types", fromlist=["ModelMessage"]).ModelMessage( + role="user", content="hi" + )], + model="w", + ) + assert seen_headers["authorization"] == f"Bearer {_FAKE_KEY}" + + +# ------------------------------------------------- config status rework + + +async def test_config_status_tier_per_user_no_cross_leak(tmp_path: Path) -> None: + """Config status shows the CURRENT user's tier: a keyed user shows + 'user', a keyless second user shows 'server' — zero runs needed + (P-1 closure), zero cross-user bleed.""" + app = _app(tmp_path, secrets_values={KEK_NAME: _KEK, "OLLAMA_API_KEY": "server-key-value"}) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin: + await _register_and_login(admin) + await admin.put( + "/api/config/settings/model-key", + json={"key": _FAKE_KEY}, + headers=_HEADERS, + ) + config = await admin.get("/api/config") + assert config.status_code == 200 + body = config.json() + assert body["resolved_tier"] == "user" + assert body["key_store"] == "enabled" + assert body["key_status"]["configured"] is True + assert body["key_status"]["masked"].endswith(_FAKE_KEY[-4:]) + assert body["cost_counters"] == {"runs_created": 0, "runs_completed": 0} + + await admin.post( + "/api/admin/users", + json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"}, + headers=_HEADERS, + ) + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second: + await second.post( + "/api/auth/login", + json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD}, + headers=_HEADERS, + ) + config = await second.get("/api/config") + body = config.json() + assert body["resolved_tier"] == "server" # no user key of their own + assert body["key_store"] == "enabled" # the STORE is enabled; the user has no key + assert body["key_status"]["configured"] is False + assert _FAKE_KEY not in config.text + assert _FAKE_KEY[-4:] not in config.text + # Counters owner-keyed: the second user sees zero, not the admin's runs. + assert body["cost_counters"] == {"runs_created": 0, "runs_completed": 0} + + +async def test_config_status_keyless_when_no_keys(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + await _register_and_login(client) + config = await client.get("/api/config") + body = config.json() + assert body["resolved_tier"] == "keyless" + assert body["key_store"] == "disabled" + assert body["key_status"]["disabled"] is True + assert body["model_adapter"] == "OllamaModelAdapter" # factory-constructed + + +async def test_config_status_works_with_zero_runs(tmp_path: Path) -> None: + """P-1: correct config status with zero runs in the registry.""" + async with await _client(tmp_path) as client: + await _register_and_login(client) + response = await client.get("/api/config") + assert response.status_code == 200 + assert response.json()["resolved_tier"] in ("user", "server", "keyless") + assert response.json()["cost_counters"]["runs_created"] == 0 + + +async def test_config_status_probe_cached_and_error_safe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Probe semantics pinned hermetically: a successful probe is + cached in-process (second call does not re-probe), and an + AdapterError probe yields probe: None (never a 500).""" + import researchx.api.app as app_module + + calls: list[int] = [] + probe_payload = { + "endpoint": "http://127.0.0.1:1/v1", + "models": ["m"], + "context_window": 8192, + "embedding_batch": 8, + "structured_outputs_supported": True, + } + from researchx.adapters.probe import CapabilityProbe + + fake_probe = CapabilityProbe( + endpoint="http://127.0.0.1:1/v1", + models=["m"], + context_window=8192, + embedding_batch=8, + structured_outputs_supported=True, + ) + + async def _fake_probe(adapter): + calls.append(1) + return fake_probe + + async def _failing_probe(adapter): + from researchx.adapters.types import AdapterError + + raise AdapterError("unreachable") + + monkeypatch.setattr(app_module, "probe_model_adapter", _fake_probe) + + async with await _client(tmp_path) as client: + await _register_and_login(client) + first = await client.get("/api/config") + assert first.status_code == 200 + assert first.json()["probe"]["endpoint"] == probe_payload["endpoint"] + assert len(calls) == 1 + second = await client.get("/api/config") + assert len(calls) == 1 # served from the (user, tier, fingerprint) cache + assert second.json()["probe"]["endpoint"] == probe_payload["endpoint"] + + # Fresh app: a failing probe yields None, cached — never a 500, and + # the route stays healthy on repeat calls. + monkeypatch.setattr(app_module, "probe_model_adapter", _failing_probe) + async with await _client(tmp_path) as client: + await _register_and_login(client) + first = await client.get("/api/config") + assert first.status_code == 200 + assert first.json()["probe"] is None + second = await client.get("/api/config") + assert second.status_code == 200 + assert second.json()["probe"] is None + + +async def test_config_status_uses_factory_adapter_not_fixed_one(tmp_path: Path) -> None: + """The rework: model_adapter names the FACTORY-constructed adapter + (OllamaModelAdapter), not the fixed injected fake.""" + async with await _client(tmp_path) as client: + await _register_and_login(client) + config = await client.get("/api/config") + assert config.json()["model_adapter"] == "OllamaModelAdapter" + + +async def test_config_status_without_factory_names_fixed_adapter(tmp_path: Path) -> None: + """Back-compat: with no factory wired (pure test harness), the + config status still answers with the injected adapter's name.""" + async with await _client(tmp_path, with_factory=False) as client: + await _register_and_login(client) + config = await client.get("/api/config") + body = config.json() + assert body["model_adapter"] == "FakeModelAdapter" + assert body["resolved_tier"] == "keyless" + + +# ----------------------------------------------- create_run tier stamping + + +async def test_create_run_stamps_credential_tier(tmp_path: Path) -> None: + """create_run resolves the factory and stamps state.credential_tier + (REQ-74); the engine contracts are otherwise unchanged.""" + async with await _client(tmp_path) as client: + me = await _register_and_login(client) + created = await client.post( + "/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS + ) + assert created.status_code == 201 + run_id = created.json()["id"] + + state_path = tmp_path / "runs" / me["user_id"] / run_id / "state.json" + import asyncio + + for _ in range(200): + if state_path.exists(): + break + await asyncio.sleep(0.01) + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["credential_tier"] == "keyless" + assert state["owner_id"] == me["user_id"] + + +async def test_create_run_user_tier_uses_user_key(tmp_path: Path) -> None: + """A keyed user's run executes with THEIR key through the factory + chain (fake model still serves the run — zero-key CI).""" + async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client: + me = await _register_and_login(client) + await client.put( + "/api/config/settings/model-key", json={"key": _FAKE_KEY}, headers=_HEADERS + ) + created = await client.post( + "/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS + ) + assert created.status_code == 201 + run_id = created.json()["id"] + + import asyncio + + state_path = tmp_path / "runs" / me["user_id"] / run_id / "state.json" + for _ in range(200): + if state_path.exists(): + break + await asyncio.sleep(0.01) + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["credential_tier"] == "user" \ No newline at end of file diff --git a/backend/uv.lock b/backend/uv.lock index 1018282..c7df438 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -260,6 +260,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + [[package]] name = "fastapi" version = "0.115.14" @@ -663,6 +713,7 @@ version = "0.2.0" source = { editable = "." } dependencies = [ { name = "argon2-cffi" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, { name = "pydantic" }, @@ -682,6 +733,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "argon2-cffi", specifier = ">=23.1,<26" }, + { name = "cryptography", specifier = ">=43" }, { name = "fastapi", specifier = ">=0.115,<0.116" }, { name = "httpx", specifier = ">=0.27,<1" }, { name = "pydantic", specifier = ">=2.8,<3" }, diff --git a/frontend/openapi.json b/frontend/openapi.json index a34d5aa..efecd29 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -264,6 +264,7 @@ "/api/config": { "get": { "summary": "Config Status", + "description": "Current user's resolved credential tier + status (T-2.6).\n\nP-1 closure: the probe is resolved via the factory for the\nCURRENT user \u2014 no run creation, no reads of the private\nregistry dict, correct with zero runs, no cross-user leak.\nA corrupt/tampered stored envelope degrades the tier honestly\n(server/keyless) and surfaces the error in key_status.reason \u2014\nnever a 500, never a silent drop.", "operationId": "config_status_api_config_get", "responses": { "200": { @@ -279,6 +280,117 @@ } } }, + "/api/config/settings": { + "get": { + "summary": "Get Settings", + "operationId": "get_settings_api_config_settings_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSettings" + } + } + } + } + } + }, + "put": { + "summary": "Put Settings", + "description": "Persist a partial settings update (REQ-72); returns the\nupdated settings.", + "operationId": "put_settings_api_config_settings_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettingsBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSettings" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/config/settings/model-key": { + "get": { + "summary": "Get Model Key Status", + "description": "Masked-only key status (never the raw key).", + "operationId": "get_model_key_status_api_config_settings_model_key_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeyStatus" + } + } + } + } + } + }, + "put": { + "summary": "Put Model Key", + "description": "Accept the user's raw model key, encrypt it at rest, return\nONLY the masked status (REQ-73 \u2014 the raw key never appears in\nany response, log, or trace). Without a KEK: honest 409.", + "operationId": "put_model_key_api_config_settings_model_key_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelKeyBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeyStatus" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/methodologies": { "get": { "summary": "Methodologies", @@ -726,6 +838,17 @@ }, "ConfigStatus": { "properties": { + "resolved_tier": { + "type": "string", + "title": "Resolved Tier" + }, + "key_store": { + "type": "string", + "title": "Key Store" + }, + "key_status": { + "$ref": "#/components/schemas/KeyStatus" + }, "model_adapter": { "type": "string", "title": "Model Adapter" @@ -760,6 +883,9 @@ }, "type": "object", "required": [ + "resolved_tier", + "key_store", + "key_status", "model_adapter", "search_adapter", "storage_dir", @@ -811,6 +937,47 @@ "type": "object", "title": "HTTPValidationError" }, + "KeyStatus": { + "properties": { + "configured": { + "type": "boolean", + "title": "Configured" + }, + "masked": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Masked" + }, + "disabled": { + "type": "boolean", + "title": "Disabled", + "default": false + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "type": "object", + "required": [ + "configured" + ], + "title": "KeyStatus", + "description": "Masked-only key status \u2014 the raw key NEVER appears (REQ-73)." + }, "LoginBody": { "properties": { "email": { @@ -881,6 +1048,22 @@ ], "title": "MethodologyCard" }, + "ModelKeyBody": { + "properties": { + "key": { + "type": "string", + "maxLength": 512, + "minLength": 1, + "title": "Key" + } + }, + "type": "object", + "required": [ + "key" + ], + "title": "ModelKeyBody", + "description": "PUT /api/config/settings/model-key body \u2014 the raw key, accepted,\nnever echoed (REQ-73)." + }, "RegisterBody": { "properties": { "email": { @@ -953,6 +1136,63 @@ ], "title": "RunSummary" }, + "SettingsBody": { + "properties": { + "planner_model": { + "anyOf": [ + { + "type": "string", + "maxLength": 200, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Planner Model" + }, + "writer_model": { + "anyOf": [ + { + "type": "string", + "maxLength": 200, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Writer Model" + }, + "methodology_defaults": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Methodology Defaults" + }, + "appearance": { + "anyOf": [ + { + "type": "string", + "maxLength": 40 + }, + { + "type": "null" + } + ], + "title": "Appearance" + } + }, + "type": "object", + "title": "SettingsBody", + "description": "PUT /api/config/settings body \u2014 partial update (D-111)." + }, "UserPublic": { "properties": { "user_id": { @@ -993,6 +1233,32 @@ "title": "UserPublic", "description": "A user as exposed on the wire \u2014 no password_hash, ever." }, + "UserSettings": { + "properties": { + "planner_model": { + "type": "string", + "title": "Planner Model", + "default": "gpt-oss:20b" + }, + "writer_model": { + "type": "string", + "title": "Writer Model", + "default": "gpt-oss:20b" + }, + "methodology_defaults": { + "additionalProperties": true, + "type": "object", + "title": "Methodology Defaults" + }, + "appearance": { + "type": "string", + "title": "Appearance", + "default": "system" + } + }, + "type": "object", + "title": "UserSettings" + }, "ValidationError": { "properties": { "loc": { diff --git a/frontend/src/components/settings-view.tsx b/frontend/src/components/settings-view.tsx index e2ecc20..a368d40 100644 --- a/frontend/src/components/settings-view.tsx +++ b/frontend/src/components/settings-view.tsx @@ -1,26 +1,32 @@ "use client"; /** - * SettingsView (T-5.4, REQ-65) — the /settings screen. + * SettingsView (T-5.4, REQ-65; P2 T-2.9, REQ-72/73/74) — /settings. * - * Five sections, all through the ApiClient boundary: - * - API keys: masked values (raw keys never reach the UI), endpoint - * labels, configured state - * - Storage: local directory reports are written to - * - Methodology defaults: default methodology + resolved 12-dial - * display (the REQ-04 reproducibility vocabulary) - * - Appearance: theme slot — preview-marked (paper palette is - * light-only today) - * - About & version: prototype notice + version - * - * Save persists through api.saveSettings; the mock round-trips it. + * Mock mode keeps the v0.1 five sections byte-for-byte (component + * tests pin them). Live/hosted mode adds the per-user surfaces on + * top, all through the ApiClient boundary: + * - Model credentials (REQ-73/74): resolved credential tier badge + * (user | server | keyless), masked model-key entry (password + * input → setModelKey; masked display "…1234"; the raw key is + * never echoed), honest disabled state when the key store is off + * (409 KeyStoreDisabledError → the reason is shown) + * - Model names (REQ-72): planner/writer inputs bound to + * getUserSettings/saveUserSettings + * - Usage (P-13): per-user cost counters (runs created/completed) + * - Storage: display-only in live mode (D-111) — the server manages + * the directory; no path picker */ import { useEffect, useState } from "react"; import { api, + liveMode, + type ConfigStatus, type Methodology, + type ModelKeyStatus, type Settings, + type UserSettings, } from "@/lib/api/client"; import { formatDialValue } from "@/lib/dials"; @@ -29,6 +35,19 @@ type SettingsState = | { kind: "error"; message: string } | { kind: "ready"; settings: Settings; methodologies: Methodology[] }; +/** Live-mode per-user panel state (T-2.9) — keyed to the boundary. */ +type UserSettingsState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; settings: UserSettings }; + +/** Resolved credential tier (REQ-74) — badge copy per tier. */ +const TIER_COPY: Record = { + user: "Your key", + server: "Server key", + keyless: "Keyless (localhost)", +}; + export function SettingsView() { const [state, setState] = useState({ kind: "loading" }); const [storage, setStorage] = useState(""); @@ -37,6 +56,19 @@ export function SettingsView() { "idle", ); + // ---- live-only per-user surface (T-2.9) -------------------------- + const [userSettings, setUserSettings] = useState({ + kind: "loading", + }); + const [configStatus, setConfigStatus] = useState(null); + const [keyDraft, setKeyDraft] = useState(""); + const [keyState, setKeyState] = useState< + { kind: "idle" } | { kind: "saving" } | { kind: "saved" } | { kind: "error"; message: string } + >({ kind: "idle" }); + const [modelsSaveState, setModelsSaveState] = useState<"idle" | "saving" | "saved" | "error">( + "idle", + ); + useEffect(() => { let cancelled = false; (async () => { @@ -64,6 +96,34 @@ export function SettingsView() { }; }, []); + // Live mode only: per-user settings + credential/usage status. + useEffect(() => { + if (!liveMode) return; + let cancelled = false; + (async () => { + try { + const [user, status] = await Promise.all([ + api.getUserSettings(), + api.getConfigStatus(), + ]); + if (!cancelled) { + setUserSettings({ kind: "ready", settings: user }); + setConfigStatus(status); + } + } catch (cause) { + if (!cancelled) { + setUserSettings({ + kind: "error", + message: cause instanceof Error ? cause.message : "Account settings unavailable", + }); + } + } + })(); + return () => { + cancelled = true; + }; + }, []); + if (state.kind === "loading") { return

Loading settings…

; } @@ -94,6 +154,49 @@ export function SettingsView() { } }; + // ---- live per-user handlers --------------------------------------- + + const userReady = userSettings.kind === "ready" ? userSettings.settings : null; + + const saveModels = async (planner: string, writer: string) => { + setModelsSaveState("saving"); + try { + const saved = await api.saveUserSettings({ + planner_model: planner, + writer_model: writer, + }); + if (userSettings.kind === "ready") { + setUserSettings({ kind: "ready", settings: saved }); + } + setModelsSaveState("saved"); + } catch { + setModelsSaveState("error"); + } + }; + + const submitModelKey = async () => { + const raw = keyDraft.trim(); + if (!raw) return; + setKeyState({ kind: "saving" }); + try { + const status = await api.setModelKey(raw); + // The raw key NEVER survives this handler — clear the field and + // keep only the masked status the boundary returned. + setKeyDraft(""); + setKeyState({ kind: "saved" }); + if (configStatus) { + setConfigStatus({ ...configStatus, key_status: status, resolved_tier: "user" }); + } + } catch (cause) { + setKeyDraft(""); + setKeyState({ + kind: "error", + message: + cause instanceof Error ? cause.message : "Could not store the key", + }); + } + }; + return (

Settings

@@ -103,6 +206,48 @@ export function SettingsView() {

+ {/* ---- Model credentials (live only, T-2.9) ----------------- */} + {liveMode && ( + + )} + + {/* ---- Model names (live only, T-2.9) ------------------------ */} + {liveMode && ( +
+

+ Model names +

+

+ The models your runs use — saved to your account and applied + to every new run. +

+ {userSettings.kind === "loading" && ( +

Loading…

+ )} + {userSettings.kind === "error" && ( +

+ {userSettings.message} +

+ )} + {userReady && ( + + )} +
+ )} + {/* ---- API keys --------------------------------------------- */}
- + {liveMode && configStatus ? ( + // D-111: server-managed, display-only in live mode — no picker. +

+ {configStatus.storage_dir} + + (server-managed — read only) + +

+ ) : ( + + )}
+ {/* ---- Usage (live only, P-13) -------------------------------- */} + {liveMode && configStatus && ( +
+

+ Usage +

+

+ Your research activity on this server. +

+
+
+
Runs created
+
+ {configStatus.cost_counters.runs_created} +
+
+
+
Runs completed
+
+ {configStatus.cost_counters.runs_completed} +
+
+
+
+ )} + {/* ---- Methodology defaults ---------------------------------- */}
void; + keyState: + | { kind: "idle" } + | { kind: "saving" } + | { kind: "saved" } + | { kind: "error"; message: string }; + onSubmit: () => void; +}) { + const tier = configStatus?.resolved_tier ?? "keyless"; + const keyStatus: ModelKeyStatus | undefined = configStatus?.key_status; + const keyStoreEnabled = configStatus?.key_store === "enabled"; + + return ( +
+

+ Model credentials +

+

+ Your key is encrypted at rest and shown masked — never displayed raw. +

+ + {/* Resolved credential tier (REQ-74) — badge + honest copy. */} +
+ Credential tier + + {TIER_COPY[tier]} + + + {tier === "user" + ? "Your runs use your stored key." + : tier === "server" + ? "Your runs use the server's key." + : "No key stored — runs fall back to keyless localhost."} + +
+ + {/* Masked key status (REQ-73). */} + {keyStatus && ( +

+ {keyStatus.configured ? ( + <> + Model key stored —{" "} + + {keyStatus.masked} + + + ) : ( + "No model key stored." + )} + {keyStatus.reason && ( + + {keyStatus.reason} + + )} +

+ )} + + {/* Key entry — disabled honestly when the store is off (409 class). */} + +
+ ); +} + +/** + * Planner/writer model-name form (REQ-72) — bound to getUserSettings / + * saveUserSettings. Editing either field enables Save; a save + * round-trips and re-renders from the returned payload. + */ +function ModelNamesForm({ + initial, + saveState, + onSave, +}: { + initial: UserSettings; + saveState: "idle" | "saving" | "saved" | "error"; + onSave: (planner: string, writer: string) => void; +}) { + const [planner, setPlanner] = useState(initial.planner_model); + const [writer, setWriter] = useState(initial.writer_model); + const [touched, setTouched] = useState(false); + + const dirty = touched && (planner !== initial.planner_model || writer !== initial.writer_model); + + return ( +
+ + +
+ + {saveState === "saved" && ( +

+ Saved — applied to your next runs. +

+ )} + {saveState === "error" && ( +

+ Save failed — try again. +

+ )} +
+
+ ); +} + /** * The resolved default-dial display — labels from the DIALS registry. * Previews the currently selected methodology (live), falling back to diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index cf52bb7..5a5f526 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -9,12 +9,14 @@ import type { CitationLedger, + ConfigStatus, ConnectionSlot, CreateUserInput, DialValues, DisplayProfile, InboxItem, Methodology, + ModelKeyStatus, ProjectGraph, RefinementQuestion, RefinedQuestion, @@ -25,6 +27,7 @@ import type { Settings, SuggestedQuery, TraceEvent, + UserSettings, UserProfile, } from "@/lib/types"; import { MockApiClient } from "@/lib/api/mock/mock-client"; @@ -34,6 +37,7 @@ export type { Report, ReportSection } from "@/lib/types"; export type { Citation, CitationLedger, + ConfigStatus, ConnectionSlot, CreateUserInput, DialValues, @@ -42,6 +46,7 @@ export type { GraphNode, InboxItem, Methodology, + ModelKeyStatus, ProjectGraph, RefinementQuestion, RefinedQuestion, @@ -58,8 +63,16 @@ export type { SuggestedQuery, Task, TraceEvent, + UserSettings, UserProfile, } from "@/lib/types"; +/** + * Typed key-store refusal (P2, REQ-73) — thrown by setModelKey when + * the backend answers 409 (key store disabled / no KEK). Exported as + * a VALUE through the boundary so consumers catch it the same way + * they catch AuthError. + */ +export { KeyStoreDisabledError } from "@/lib/types"; /** Input to createRun — the Ask + Choose screens' combined payload. */ export interface CreateRunInput { @@ -176,6 +189,44 @@ export interface ApiClient { */ saveSettings(update: Partial): Promise; + /* ---------------------------------------------------------------- */ + /* v0.3 P2 per-user settings + model keys (T-2.9 — REQ-72/73/74). */ + /* C3-18-adjacent extension: the exhaustive P2 additions. The */ + /* v0.1 Settings surface stays untouched (mock-mode screens keep */ + /* their behavior byte-for-byte); the per-user surface binds to */ + /* GET/PUT /api/config/settings and the model-key routes. */ + /* ---------------------------------------------------------------- */ + + /** + * The current user's per-user settings (REQ-72 — GET + * /api/config/settings). Fresh users get the server defaults + * (planner/writer "gpt-oss:20b", appearance "system"). + */ + getUserSettings(): Promise; + + /** + * Persist a partial per-user settings update (REQ-72 — PUT + * /api/config/settings, CSRF header in live mode); returns the + * merged UserSettings. + */ + saveUserSettings(update: Partial): Promise; + + /** + * Store the user's raw model key (REQ-73 — PUT + * /api/config/settings/model-key). The raw key is sent once and + * NEVER echoed: the resolved value is masked-only. A disabled + * server-side key store rejects with a typed KeyStoreDisabledError + * carrying the honest reason (409) — never a silent no-op. + */ + setModelKey(key: string): Promise; + + /** + * The current user's credential/usage status (REQ-74 — GET + * /api/config): resolved tier, key-store state, masked key status, + * adapters, storage dir (display-only), probe, cost counters. + */ + getConfigStatus(): Promise; + /** * Follow-up query suggestions for the New Research view — * derived from unanswered open questions of past runs diff --git a/frontend/src/lib/api/generated/schema.d.ts b/frontend/src/lib/api/generated/schema.d.ts index 6e8adca..ba18ec6 100644 --- a/frontend/src/lib/api/generated/schema.d.ts +++ b/frontend/src/lib/api/generated/schema.d.ts @@ -131,7 +131,17 @@ export interface paths { path?: never; cookie?: never; }; - /** Config Status */ + /** + * Config Status + * @description Current user's resolved credential tier + status (T-2.6). + * + * P-1 closure: the probe is resolved via the factory for the + * CURRENT user — no run creation, no reads of the private + * registry dict, correct with zero runs, no cross-user leak. + * A corrupt/tampered stored envelope degrades the tier honestly + * (server/keyless) and surfaces the error in key_status.reason — + * never a 500, never a silent drop. + */ get: operations["config_status_api_config_get"]; put?: never; post?: never; @@ -141,6 +151,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/config/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Settings */ + get: operations["get_settings_api_config_settings_get"]; + /** + * Put Settings + * @description Persist a partial settings update (REQ-72); returns the + * updated settings. + */ + put: operations["put_settings_api_config_settings_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/config/settings/model-key": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Model Key Status + * @description Masked-only key status (never the raw key). + */ + get: operations["get_model_key_status_api_config_settings_model_key_get"]; + /** + * Put Model Key + * @description Accept the user's raw model key, encrypt it at rest, return + * ONLY the masked status (REQ-73 — the raw key never appears in + * any response, log, or trace). Without a KEK: honest 409. + */ + put: operations["put_model_key_api_config_settings_model_key_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/methodologies": { parameters: { query?: never; @@ -341,6 +399,11 @@ export interface components { }; /** ConfigStatus */ ConfigStatus: { + /** Resolved Tier */ + resolved_tier: string; + /** Key Store */ + key_store: string; + key_status: components["schemas"]["KeyStatus"]; /** Model Adapter */ model_adapter: string; /** Search Adapter */ @@ -377,6 +440,23 @@ export interface components { /** Detail */ detail?: components["schemas"]["ValidationError"][]; }; + /** + * KeyStatus + * @description Masked-only key status — the raw key NEVER appears (REQ-73). + */ + KeyStatus: { + /** Configured */ + configured: boolean; + /** Masked */ + masked?: string | null; + /** + * Disabled + * @default false + */ + disabled: boolean; + /** Reason */ + reason?: string | null; + }; /** LoginBody */ LoginBody: { /** Email */ @@ -405,6 +485,15 @@ export interface components { [key: string]: unknown; }; }; + /** + * ModelKeyBody + * @description PUT /api/config/settings/model-key body — the raw key, accepted, + * never echoed (REQ-73). + */ + ModelKeyBody: { + /** Key */ + key: string; + }; /** RegisterBody */ RegisterBody: { /** Email */ @@ -434,6 +523,22 @@ export interface components { /** Started At */ started_at: string; }; + /** + * SettingsBody + * @description PUT /api/config/settings body — partial update (D-111). + */ + SettingsBody: { + /** Planner Model */ + planner_model?: string | null; + /** Writer Model */ + writer_model?: string | null; + /** Methodology Defaults */ + methodology_defaults?: { + [key: string]: unknown; + } | null; + /** Appearance */ + appearance?: string | null; + }; /** * UserPublic * @description A user as exposed on the wire — no password_hash, ever. @@ -455,6 +560,28 @@ export interface components { */ created_at: string; }; + /** UserSettings */ + UserSettings: { + /** + * Planner Model + * @default gpt-oss:20b + */ + planner_model: string; + /** + * Writer Model + * @default gpt-oss:20b + */ + writer_model: string; + /** Methodology Defaults */ + methodology_defaults?: { + [key: string]: unknown; + }; + /** + * Appearance + * @default system + */ + appearance: string; + }; /** ValidationError */ ValidationError: { /** Location */ @@ -707,6 +834,112 @@ export interface operations { }; }; }; + get_settings_api_config_settings_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserSettings"]; + }; + }; + }; + }; + put_settings_api_config_settings_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SettingsBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserSettings"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_model_key_status_api_config_settings_model_key_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["KeyStatus"]; + }; + }; + }; + }; + put_model_key_api_config_settings_model_key_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ModelKeyBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["KeyStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; methodologies_api_methodologies_get: { parameters: { query?: never; diff --git a/frontend/src/lib/api/live-client.ts b/frontend/src/lib/api/live-client.ts index 6fdb87d..704fbc7 100644 --- a/frontend/src/lib/api/live-client.ts +++ b/frontend/src/lib/api/live-client.ts @@ -17,13 +17,17 @@ import createClient from "openapi-fetch"; import type { paths } from "@/lib/api/generated/schema"; import type { CitationLedger, + ConfigStatus, CreateUserInput, Methodology, + ModelKeyStatus, Report, ResearchRun, TraceEvent, + UserSettings, UserProfile, } from "@/lib/types"; +import { KeyStoreDisabledError } from "@/lib/types"; import type { CreateRunInput, RunEventSubscription, @@ -189,6 +193,144 @@ export class GeneratedApiClient extends MockApiClient { return this.#toProfile(data); } + /* ---------------------------------------------------------------- */ + /* v0.3 P2 per-user settings + model keys (T-2.9, REQ-72..74). */ + /* Same fetch discipline as the session surface: credentials */ + /* "include" everywhere, CSRF header on every mutation. The raw */ + /* model key crosses the wire exactly once (the PUT body) and is */ + /* never echoed — only the masked KeyStatus comes back. */ + /* ---------------------------------------------------------------- */ + + /** Map the backend's UserSettings to the frontend shape (1:1). */ + #toUserSettings(payload: Record): UserSettings { + return { + planner_model: String(payload.planner_model ?? "gpt-oss:20b"), + writer_model: String(payload.writer_model ?? "gpt-oss:20b"), + methodology_defaults: + (payload.methodology_defaults as UserSettings["methodology_defaults"]) ?? {}, + appearance: String(payload.appearance ?? "system"), + }; + } + + /** Map the backend's KeyStatus — masked-only, never the raw key. */ + #toKeyStatus(payload: Record): ModelKeyStatus { + return { + configured: Boolean(payload.configured), + masked: payload.masked == null ? null : String(payload.masked), + disabled: Boolean(payload.disabled ?? false), + reason: payload.reason == null ? null : String(payload.reason), + }; + } + + override async getUserSettings(): Promise { + const { data, error } = await this.#client.GET("/api/config/settings", { + credentials: "include", + }); + if (error || !data) { + throw new Error("live: failed to load settings"); + } + return this.#toUserSettings(data as Record); + } + + override async saveUserSettings( + update: Partial, + ): Promise { + // Partial body: only the fields the caller set travel on the wire. + const body: Record = {}; + if (update.planner_model !== undefined) { + body.planner_model = update.planner_model; + } + if (update.writer_model !== undefined) { + body.writer_model = update.writer_model; + } + if (update.methodology_defaults !== undefined) { + body.methodology_defaults = update.methodology_defaults; + } + if (update.appearance !== undefined) { + body.appearance = update.appearance; + } + const { data, error, response } = await this.#client.PUT( + "/api/config/settings", + { + body, + credentials: "include", + headers: { [CSRF_HEADER]: "1" }, + }, + ); + if (error || !data) { + throw new Error(`live: failed to save settings (${response.status})`); + } + return this.#toUserSettings(data as Record); + } + + override async setModelKey(key: string): Promise { + const { data, error, response } = await this.#client.PUT( + "/api/config/settings/model-key", + { + body: { key }, + credentials: "include", + headers: { [CSRF_HEADER]: "1" }, + }, + ); + if (error || !data) { + // 409: the server's key store is disabled (no KEK) — surface the + // honest reason from the backend's detail payload, never a + // silent no-op and never the raw key in any error copy. + if (response.status === 409) { + // Backend shape: {detail: {key_store, reason}} (FastAPI wraps + // the error detail); openapi-fetch hands us the parsed body. + const payload = error as unknown; + const detail = + typeof payload === "object" && + payload !== null && + "detail" in payload && + typeof (payload as { detail: unknown }).detail === "object" + ? ((payload as { detail: Record }).detail) + : (payload as Record); + const rawReason = + typeof detail === "string" + ? detail + : detail && typeof detail === "object" + ? (detail as { reason?: unknown }).reason + : undefined; + const reason = + (typeof rawReason === "string" && rawReason) || + (typeof detail === "object" && detail !== null + ? String((detail as { detail?: unknown }).detail ?? "key store disabled") + : "key store disabled"); + throw new KeyStoreDisabledError(reason); + } + throw new Error(`live: failed to store model key (${response.status})`); + } + return this.#toKeyStatus(data as Record); + } + + override async getConfigStatus(): Promise { + const { data, error } = await this.#client.GET("/api/config", { + credentials: "include", + }); + if (error || !data) { + throw new Error("live: failed to load config status"); + } + const backend = data as Record; + const keyStatus = backend.key_status as Record | undefined; + const counters = + (backend.cost_counters as Record | undefined) ?? {}; + return { + resolved_tier: (backend.resolved_tier as ConfigStatus["resolved_tier"]) ?? "keyless", + key_store: (backend.key_store as ConfigStatus["key_store"]) ?? "disabled", + key_status: this.#toKeyStatus(keyStatus ?? {}), + model_adapter: String(backend.model_adapter ?? "unknown"), + search_adapter: String(backend.search_adapter ?? "unknown"), + storage_dir: String(backend.storage_dir ?? ""), + probe: (backend.probe as Record | null | undefined) ?? null, + cost_counters: { + runs_created: Number(counters.runs_created ?? 0), + runs_completed: Number(counters.runs_completed ?? 0), + }, + }; + } + override async getMethodologies(): Promise { const { data, error } = await this.#client.GET("/api/methodologies"); if (error || !data) { diff --git a/frontend/src/lib/api/mock/mock-client.ts b/frontend/src/lib/api/mock/mock-client.ts index 737772c..0eb79f2 100644 --- a/frontend/src/lib/api/mock/mock-client.ts +++ b/frontend/src/lib/api/mock/mock-client.ts @@ -23,6 +23,7 @@ import { reportConnectionsFixture, reportLibraryFixture, settingsFixture, + userSettingsFixture, userProfileFixture, } from "@/lib/api/mock/shell-fixtures"; import { @@ -33,11 +34,13 @@ import { } from "@/lib/api/mock/scripted-run"; import type { CitationLedger, + ConfigStatus, ConnectionSlot, CreateUserInput, DisplayProfile, InboxItem, Methodology, + ModelKeyStatus, ProjectGraph, RefinementQuestion, RefinedQuestion, @@ -48,6 +51,7 @@ import type { Settings, SuggestedQuery, TraceEvent, + UserSettings, UserProfile, } from "@/lib/types"; @@ -311,6 +315,74 @@ export class MockApiClient implements ApiClient { /** Session-persisted settings (mock-local). */ #settings: Settings = structuredClone(settingsFixture); + /* ---------------------------------------------------------------- */ + /* v0.3 P2 per-user settings + model keys (T-2.9, REQ-72..74). */ + /* The mock is an honest zero-backend fixture: it mirrors the */ + /* backend's DEFAULT-disabled key store (no KEK in mock mode) so */ + /* the UI's degraded states are exercised, not hidden. */ + /* ---------------------------------------------------------------- */ + + /** Per-user settings — starts from the backend's fresh-user defaults. */ + #userSettings: UserSettings = structuredClone(userSettingsFixture); + + /** In-memory masked key state — the raw key is NEVER stored. */ + #modelKey: ModelKeyStatus = { + configured: false, + masked: null, + disabled: false, + reason: null, + }; + + /** Per-user settings (REQ-72) — session-local, defaults for fresh users. */ + async getUserSettings(): Promise { + return structuredClone(this.#userSettings); + } + + /** Persist a partial per-user settings update (REQ-72) — in-memory. */ + async saveUserSettings(update: Partial): Promise { + const next: UserSettings = { + ...this.#userSettings, + ...structuredClone(update), + }; + this.#userSettings = next; + return structuredClone(next); + } + + /** + * Store the raw model key (REQ-73): the mock keeps ONLY the masked + * tail in memory — the raw value is accepted, masked, and dropped. + * Mirrors the backend's mask shape ("…" + last 4). + */ + async setModelKey(key: string): Promise { + const masked = `…${key.slice(-4)}`; + this.#modelKey = { + configured: true, + masked: key.length >= 4 ? masked : "…", + disabled: false, + reason: null, + }; + return structuredClone(this.#modelKey); + } + + /** + * Honest mock config status (REQ-74): the mock has no per-user key + * store (zero-backend fixture), so resolved_tier degrades to + * "keyless" — the documented keyless-localhost tier — and key_store + * reports "disabled" exactly like a backend without a KEK. + */ + async getConfigStatus(): Promise { + return { + resolved_tier: "keyless", + key_store: "disabled", + key_status: structuredClone(this.#modelKey), + model_adapter: "FakeModelAdapter", + search_adapter: "FakeSearchAdapter", + storage_dir: settingsFixture.storageDirectory, + probe: null, + cost_counters: { runs_created: 0, runs_completed: 0 }, + }; + } + /** * Follow-up suggestions from unanswered open questions (REQ-67). * Derived live from the inbox fixtures — a completed item drops diff --git a/frontend/src/lib/api/mock/shell-fixtures.ts b/frontend/src/lib/api/mock/shell-fixtures.ts index 99d1d1c..b0fdf59 100644 --- a/frontend/src/lib/api/mock/shell-fixtures.ts +++ b/frontend/src/lib/api/mock/shell-fixtures.ts @@ -5,9 +5,15 @@ * masked API-key settings, adapter-slot connection status, and the local * single-user profile. Clearly example content, consistent with the * battery-research domain used across the prototype fixtures. + * + * v0.3 P2 (T-2.9): adds the per-user settings fixture (the backend's + * fresh-user defaults — planner/writer "gpt-oss:20b", appearance + * "system") and the honest keyless/degraded credential fields on the + * model connection slot (resolvedTier "keyless", keyStore "disabled" — + * the mock has no key store, exactly like a backend without a KEK). */ -import type { Settings } from "@/lib/types"; +import type { Settings, UserSettings } from "@/lib/types"; import type { DisplayProfile, ReportConnection, @@ -157,6 +163,19 @@ export const settingsFixture: Settings = { theme: "paper", }; +/** + * Per-user settings fixture (P2, REQ-72) — mirrors the backend's + * fresh-user defaults exactly (auth/settings.py: planner/writer + * "gpt-oss:20b", appearance "system"). The mock has no per-user + * persistence across sessions; this is the starting payload. + */ +export const userSettingsFixture: UserSettings = { + planner_model: "gpt-oss:20b", + writer_model: "gpt-oss:20b", + methodology_defaults: {}, + appearance: "system", +}; + /* ------------------------------------------------------------------ */ /* Connection status (REQ-65 — adapter-slot health) */ /* ------------------------------------------------------------------ */ @@ -169,6 +188,18 @@ export const connectionStatusFixture: ConnectionSlot[] = [ label: "Model", status: "connected", detail: "Ollama cloud — key detected", + // P2 (REQ-74) honest mock: the zero-backend fixture has no + // per-user key store and no server key, so the tier degrades to + // keyless localhost — the documented keyless path, not a lie. + resolvedTier: "keyless", + keyStore: "disabled", + keyStatus: { + configured: false, + masked: null, + disabled: true, + reason: + "mock fixture: no key store (zero-backend mode) — the credential chain stays keyless", + }, }, { slot: "search", diff --git a/frontend/src/lib/api/user-settings-boundary.test.ts b/frontend/src/lib/api/user-settings-boundary.test.ts new file mode 100644 index 0000000..c36e767 --- /dev/null +++ b/frontend/src/lib/api/user-settings-boundary.test.ts @@ -0,0 +1,313 @@ +/** + * Per-user settings + model-key boundary tests (T-2.9, REQ-72/73/74). + * + * The v0.3 P2 ApiClient additions — getUserSettings / saveUserSettings / + * setModelKey / getConfigStatus — contract-tested against BOTH bindings: + * - the mock binding: honest zero-backend fixture (fresh-user defaults, + * in-memory masked key state, keyless tier, key store "disabled"), and + * - the live binding against a stubbed global fetch (no network, no + * keys): real routes, CSRF header on every mutation (C3-05), + * credentials:"include" cookie discipline, masked-only key echo, + * and the honest 409 KeyStoreDisabledError refusal. + */ + +import { describe, expect, it } from "vitest"; + +// client.ts first (runtime import — it binds MockApiClient before the +// live client extends it); KeyStoreDisabledError is consumed through +// the boundary like AuthError in the session tests. +import { KeyStoreDisabledError, type ApiClient } from "@/lib/api/client"; +import { MockApiClient } from "@/lib/api/mock/mock-client"; +import { GeneratedApiClient } from "@/lib/api/live-client"; + +/* ------------------------------------------------------------------ */ +/* Mock binding — standalone per-user fixture */ +/* ------------------------------------------------------------------ */ + +describe("MockApiClient per-user settings surface (T-2.9)", () => { + it("getUserSettings returns the fresh-user defaults (planner/writer gpt-oss:20b)", async () => { + const client: ApiClient = new MockApiClient(); + const settings = await client.getUserSettings(); + expect(settings.planner_model).toBe("gpt-oss:20b"); + expect(settings.writer_model).toBe("gpt-oss:20b"); + expect(settings.methodology_defaults).toEqual({}); + expect(settings.appearance).toBe("system"); + }); + + it("saveUserSettings updates in-memory state and round-trips", async () => { + const client: ApiClient = new MockApiClient(); + const saved = await client.saveUserSettings({ + planner_model: "gpt-oss:120b", + writer_model: "llama3.1:70b", + }); + expect(saved.planner_model).toBe("gpt-oss:120b"); + expect(saved.writer_model).toBe("llama3.1:70b"); + // Fresh read sees the persisted payload. + const reread = await client.getUserSettings(); + expect(reread.planner_model).toBe("gpt-oss:120b"); + expect(reread.writer_model).toBe("llama3.1:70b"); + }); + + it("saveUserSettings keeps unset fields (partial update semantics)", async () => { + const client: ApiClient = new MockApiClient(); + const saved = await client.saveUserSettings({ planner_model: "mistral:small" }); + // writer_model was not in the update — the default survives. + expect(saved.planner_model).toBe("mistral:small"); + expect(saved.writer_model).toBe("gpt-oss:20b"); + }); + + it("setModelKey stores ONLY the masked tail in memory — never the raw key", async () => { + const client: ApiClient = new MockApiClient(); + const status = await client.setModelKey("sk-live-raw-secret-7f2a"); + expect(status.configured).toBe(true); + expect(status.masked).toBe("…7f2a"); + expect(status.disabled).toBe(false); + // getConfigStatus reflects the stored masked state — the raw key + // never appears in ANY response the boundary produces. + const config = await client.getConfigStatus(); + expect(config.key_status.configured).toBe(true); + expect(config.key_status.masked).toBe("…7f2a"); + expect(JSON.stringify(config)).not.toContain("sk-live-raw-secret-7f2a"); + }); + + it("getConfigStatus is honest: keyless tier, disabled key store", async () => { + const client: ApiClient = new MockApiClient(); + const config = await client.getConfigStatus(); + // The mock has no key store and no server key — the honest fixture. + expect(config.resolved_tier).toBe("keyless"); + expect(config.key_store).toBe("disabled"); + expect(config.cost_counters).toEqual({ runs_created: 0, runs_completed: 0 }); + expect(config.storage_dir).toBeTruthy(); + }); + + it("connection status carries the per-user tier extension on the model slot", async () => { + const client: ApiClient = new MockApiClient(); + const slots = await client.getConnectionStatus(); + const model = slots.find((s) => s.slot === "model"); + expect(model?.resolvedTier).toBe("keyless"); + expect(model?.keyStore).toBe("disabled"); + expect(model?.keyStatus?.disabled).toBe(true); + expect(model?.keyStatus?.reason).toBeTruthy(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Live binding — real routes against a stubbed fetch */ +/* ------------------------------------------------------------------ */ + +type RouteHandler = ( + url: string, + init?: RequestInit, + body?: string, +) => { status: number; body: unknown }; + +function stubRoutes(routes: Record): typeof globalThis.fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : undefined; + const url = request ? request.url : String(input); + // openapi-fetch serializes the body onto the Request object — + // surface it (plus credentials/headers) back to the route handler + // so the assertions see exactly what went over the wire. + // request.body is a ReadableStream — read it back out. + let body: string | undefined; + let sentInit: RequestInit | undefined = init; + if (request) { + const cloned = request.clone(); + body = cloned.body ? await cloned.text() : undefined; + sentInit = { + ...init, + method: request.method, + credentials: request.credentials, + headers: request.headers, + }; + } + for (const [prefix, handler] of Object.entries(routes)) { + if (url.startsWith(prefix)) { + const { status, body: responseBody } = handler(url, sentInit, body); + return new Response(JSON.stringify(responseBody), { + status, + headers: { "Content-Type": "application/json" }, + }); + } + } + return new Response(JSON.stringify({ detail: "not found" }), { status: 404 }); + }) as typeof globalThis.fetch; +} + +/** Headers actually sent on a call (CSRF + cookie assertions). */ +function headersOf(init?: RequestInit): Record { + const raw = init?.headers; + const record: Record = {}; + if ( + typeof raw === "object" && + raw !== null && + typeof (raw as Headers).forEach === "function" + ) { + (raw as Headers).forEach((v, k) => { + record[k] = v; + }); + } else if (Array.isArray(raw)) { + for (const [k, v] of raw) record[String(k)] = String(v); + } else if (raw) { + Object.assign(record, raw); + } + return record; +} + +const backendUserSettings = { + planner_model: "gpt-oss:20b", + writer_model: "gpt-oss:20b", + methodology_defaults: {}, + appearance: "system", +}; + +const backendKeyStatus = { + configured: true, + masked: "…7f2a", + disabled: false, + reason: null, +}; + +const backendConfigStatus = { + resolved_tier: "user", + key_store: "enabled", + key_status: backendKeyStatus, + model_adapter: "OllamaModelAdapter", + search_adapter: "TavilySearchAdapter", + storage_dir: "/var/lib/researchx/runs", + probe: { endpoint: "https://ollama.com/v1", structured_outputs_supported: true }, + cost_counters: { runs_created: 4, runs_completed: 3 }, +}; + +describe("GeneratedApiClient per-user settings surface (live binding, T-2.9)", () => { + it("getUserSettings hits GET /api/config/settings with cookie discipline", async () => { + const routes = { + "http://127.0.0.1:8000/api/config/settings": (_url: string, init?: RequestInit) => { + // GET, never a mutation — but the cookie still rides along. + expect(init?.credentials).toBe("include"); + return { status: 200, body: backendUserSettings }; + }, + }; + const client = new GeneratedApiClient(stubRoutes(routes)); + const settings = await client.getUserSettings(); + expect(settings.planner_model).toBe("gpt-oss:20b"); + expect(settings.appearance).toBe("system"); + }); + + it("saveUserSettings PUTs a partial body with the CSRF header", async () => { + const routes = { + "http://127.0.0.1:8000/api/config/settings": ( + _url: string, + init?: RequestInit, + body?: string, + ) => { + expect(headersOf(init)["x-researchx-request"]).toBe("1"); + expect(init?.credentials).toBe("include"); + // Partial body: ONLY the field the caller set travels. + const parsed = JSON.parse(body ?? "null") as Record; + expect(Object.keys(parsed)).toEqual(["planner_model"]); + return { + status: 200, + body: { ...backendUserSettings, planner_model: "gpt-oss:120b" }, + }; + }, + }; + const client = new GeneratedApiClient(stubRoutes(routes)); + const saved = await client.saveUserSettings({ planner_model: "gpt-oss:120b" }); + expect(saved.planner_model).toBe("gpt-oss:120b"); + }); + + it("setModelKey PUTs the raw key once and resolves ONLY the masked status", async () => { + const routes = { + "http://127.0.0.1:8000/api/config/settings/model-key": ( + _url: string, + init?: RequestInit, + body?: string, + ) => { + expect(headersOf(init)["x-researchx-request"]).toBe("1"); + expect(init?.credentials).toBe("include"); + const parsed = JSON.parse(body ?? "null") as { key: string }; + expect(parsed.key).toBe("sk-live-raw-secret-7f2a"); + // The response is masked-only — the raw key never echoes. + return { status: 200, body: backendKeyStatus }; + }, + }; + const client = new GeneratedApiClient(stubRoutes(routes)); + const status = await client.setModelKey("sk-live-raw-secret-7f2a"); + expect(status.configured).toBe(true); + expect(status.masked).toBe("…7f2a"); + expect(JSON.stringify(status)).not.toContain("sk-live-raw-secret-7f2a"); + }); + + it("setModelKey surfaces a disabled key store as a typed 409 with the honest reason", async () => { + const routes = { + "http://127.0.0.1:8000/api/config/settings/model-key": () => ({ + status: 409, + body: { + detail: { + key_store: "disabled", + reason: + "no RESEARCHX_KEK configured (env-file secrets 'keys' scope); per-user model keys cannot be stored", + }, + }, + }), + }; + const client = new GeneratedApiClient(stubRoutes(routes)); + const error = await client + .setModelKey("sk-anything") + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(KeyStoreDisabledError); + const typed = error as KeyStoreDisabledError; + expect(typed.reason).toContain("RESEARCHX_KEK"); + // Honest refusal — never a silent no-op, never the raw key. + expect(JSON.stringify(typed)).not.toContain("sk-anything"); + }); + + it("getConfigStatus maps the full config payload to the frontend shape", async () => { + const routes = { + "http://127.0.0.1:8000/api/config": (_url: string, init?: RequestInit) => { + expect(init?.credentials).toBe("include"); + return { status: 200, body: backendConfigStatus }; + }, + }; + const client = new GeneratedApiClient(stubRoutes(routes)); + const config = await client.getConfigStatus(); + expect(config.resolved_tier).toBe("user"); + expect(config.key_store).toBe("enabled"); + expect(config.key_status.masked).toBe("…7f2a"); + expect(config.model_adapter).toBe("OllamaModelAdapter"); + expect(config.storage_dir).toBe("/var/lib/researchx/runs"); + expect(config.probe).not.toBeNull(); + expect(config.cost_counters).toEqual({ runs_created: 4, runs_completed: 3 }); + }); + + it("getConfigStatus tolerates a keyless degraded backend honestly", async () => { + const routes = { + "http://127.0.0.1:8000/api/config": () => ({ + status: 200, + body: { + resolved_tier: "keyless", + key_store: "disabled", + key_status: { + configured: false, + masked: null, + disabled: true, + reason: "no RESEARCHX_KEK configured", + }, + model_adapter: "OllamaModelAdapter", + search_adapter: "DuckDuckGoSearchAdapter", + storage_dir: "/var/lib/researchx/runs", + probe: null, + cost_counters: { runs_created: 0, runs_completed: 0 }, + }, + }), + }; + const client = new GeneratedApiClient(stubRoutes(routes)); + const config = await client.getConfigStatus(); + expect(config.resolved_tier).toBe("keyless"); + expect(config.key_store).toBe("disabled"); + expect(config.key_status.disabled).toBe(true); + expect(config.key_status.reason).toContain("RESEARCHX_KEK"); + expect(config.cost_counters).toEqual({ runs_created: 0, runs_completed: 0 }); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a47e660..f148515 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -382,6 +382,34 @@ export interface ConnectionSlot { status: "connected" | "fallback" | "preview"; /** Human detail, e.g. "Ollama cloud — key detected" or "Preview until v0.2". */ detail: string; + /* ------------------------------------------------------------------ */ + /* v0.3 P2 per-user credential fields (T-2.9, REQ-74). The resolved */ + /* tier applies to the "model" slot; the key fields mirror the */ + /* backend's GET /api/config. Mock mode fills honest fixture values */ + /* (keyless / key store disabled). Older consumers that only read */ + /* slot/label/status/detail stay source-compatible. */ + /* ------------------------------------------------------------------ */ + /** Credential tier this user's runs resolve to (model slot only). */ + resolvedTier?: "user" | "server" | "keyless"; + /** Whether the per-user key store is enabled server-side. */ + keyStore?: "enabled" | "disabled"; + /** Masked-only per-user model-key status — never the raw key. */ + keyStatus?: ModelKeyStatus; +} + +/** + * Masked-only model-key status (P2, REQ-73 — mirrors the backend's + * KeyStatus). The raw key NEVER appears in any field. + */ +export interface ModelKeyStatus { + /** True when a key is stored for the current user. */ + configured: boolean; + /** Masked tail — "…7f2a" shape; null when nothing is stored. */ + masked: string | null; + /** True when the key store is disabled server-side (no KEK). */ + disabled: boolean; + /** Honest reason for a disabled/unreadable store — surfaced to the UI. */ + reason: string | null; } /** The whole settings payload (maps to GET/PUT /api/config in v0.2). */ @@ -397,6 +425,64 @@ export interface Settings { theme: "paper"; } +/** + * Per-user settings (P2, REQ-72 — mirrors the backend's UserSettings + * from GET/PUT /api/config/settings). Model names + methodology + * defaults + appearance, one payload per user; fresh users get the + * server defaults (planner/writer "gpt-oss:20b", appearance "system"). + */ +export interface UserSettings { + /** Model name the planner role runs (e.g. "gpt-oss:20b"). */ + planner_model: string; + /** Model name the writer role runs. */ + writer_model: string; + /** Resolved dial overrides per methodology — free-form, validated at use. */ + methodology_defaults: Record; + /** Appearance slot — "system" (the light palette is the only render today). */ + appearance: string; +} + +/** + * The per-user credential/usage status from GET /api/config (P2, + * REQ-74) — the live-mode settings surface. Every field is + * display-only except keyStatus, which drives the masked key entry. + */ +export interface ConfigStatus { + /** Credential tier the CURRENT user's runs resolve to. */ + resolved_tier: "user" | "server" | "keyless"; + /** Whether the server's per-user key store is enabled. */ + key_store: "enabled" | "disabled"; + /** Masked-only key status — never the raw key. */ + key_status: ModelKeyStatus; + /** Adapter class names (display-only). */ + model_adapter: string; + search_adapter: string; + /** Server-managed storage directory (display-only — D-111, no picker). */ + storage_dir: string; + /** Capability-probe snapshot, when the adapter supports probing. */ + probe: Record | null; + /** Per-user usage counters (P-13). */ + cost_counters: { + runs_created: number; + runs_completed: number; + }; +} + +/** + * A typed key-store refusal (P2, REQ-73): PUT model-key answered 409 + * because the server's key store is disabled (no KEK). Carries the + * honest reason so the settings UI can say WHY, never a silent no-op. + */ +export class KeyStoreDisabledError extends Error { + readonly reason: string; + + constructor(reason: string) { + super(reason); + this.name = "KeyStoreDisabledError"; + this.reason = reason; + } +} + /** * The menu/display profile behind the avatar button (REQ-66). * v0.2 prototype shape — name + initials + tagline for the local