Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccb373dee1 | |||
| ee26ff1658 |
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"phase": 1,
|
||||
"phase": 2,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.3",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-29T21:40:00Z"
|
||||
"updated_at": "2026-09-29T22:15:00Z"
|
||||
}
|
||||
@@ -162,7 +162,7 @@
|
||||
| REQ-17, REQ-32, REQ-34 (adversarial, E2E, round-trip) | v0.2 | P4 | complete (v0.2.4) |
|
||||
| All REQ-01..34 (milestone gate) | v0.2 | P5 | complete (v0.2.5 — **owner approval recorded 2026-09-27**, G-009 ratified; milestone merged to main) |
|
||||
| REQ-69..71 (auth, sessions, scoping) | v0.3 | P1 | complete (v0.3.1; security review P0 register race + P1 corrupt-store fail-closed fixed in-phase; verify PASS 4/4) |
|
||||
| REQ-72..74 (settings, per-user keys, resolution chain) | v0.3 | P2 | pending |
|
||||
| REQ-72..74 (settings, per-user keys, resolution chain) | v0.3 | P2 | complete (v0.3.2; P0 null-poisoning fixed in verify; P-1/P-2/P-13 closed; production main entrypoint P-3 backend half) |
|
||||
| REQ-76..82 (live surfaces, user-scoped) | v0.3 | P3 | pending |
|
||||
| REQ-75 (delegation-end half) + REQ-83..85 (hosting; live default hosted) | v0.3 | P3/P4 (REQ-75 split per G-103: delegation-end P3, hosted-default P4) | pending |
|
||||
| REQ-35..51 | v2 backlog (post-MVP milestones) | — | pending |
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ Scope (owner session 2026-09-29): one milestone combining the G-009-ratified liv
|
||||
- **Requirements covered**: REQ-72, REQ-73, REQ-74
|
||||
- **Success criteria**: a keyed user gets fully real inference end-to-end; keys never appear in API responses (masked only), logs, or traces; resolution tier surfaced in config status; zero-key tests stay green via REQ-31 fakes
|
||||
- **Depends on**: 1
|
||||
- **Status**: pending
|
||||
- **Status**: complete (PR #18 squash-merged to milestone/v0.3-hosted ee26ff1, tag v0.3.2, release HTTP 201, phase branch deleted; verify PASS-WITH-FIXES — P0 settings null-poisoning fixed in-phase; 264 backend + 131 frontend tests)
|
||||
|
||||
### Phase 3 — Live Surfaces (user-scoped)
|
||||
- **Description**: Library read-model (GET /api/library), connections + graph (GET /api/library/connections, GET /api/graph), task inbox (GET /api/tasks), suggestions (GET /api/suggestions), refine (POST /api/refine/*, real planner calls), profile (GET/PUT /api/profile), live Reading Room fidelity (structured report, anchored citations, ledger panel). G-002 boundary lifted; frontend live swaps with zero component rewrites (REQ-54).
|
||||
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"sse-starlette>=2.1,<3",
|
||||
"httpx>=0.27,<1",
|
||||
"argon2-cffi>=23.1,<26",
|
||||
"cryptography>=43",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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/<user_id>/<run_id>/."""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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/<user_id>.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/<user_id>.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"]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Per-user settings persistence (T-2.3, REQ-72 — D-111).
|
||||
|
||||
``settings/<user_id>.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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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/<uid>.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)
|
||||
@@ -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/<uid>.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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Generated
+52
@@ -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" },
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<ConfigStatus["resolved_tier"], string> = {
|
||||
user: "Your key",
|
||||
server: "Server key",
|
||||
keyless: "Keyless (localhost)",
|
||||
};
|
||||
|
||||
export function SettingsView() {
|
||||
const [state, setState] = useState<SettingsState>({ 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<UserSettingsState>({
|
||||
kind: "loading",
|
||||
});
|
||||
const [configStatus, setConfigStatus] = useState<ConfigStatus | null>(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 <p className="mt-8 text-small text-ink-faint">Loading settings…</p>;
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<h1 className="text-display font-semibold text-ink">Settings</h1>
|
||||
@@ -103,6 +206,48 @@ export function SettingsView() {
|
||||
</p>
|
||||
|
||||
<div className="mt-block space-y-8">
|
||||
{/* ---- Model credentials (live only, T-2.9) ----------------- */}
|
||||
{liveMode && (
|
||||
<ModelCredentialsSection
|
||||
configStatus={configStatus}
|
||||
keyDraft={keyDraft}
|
||||
onKeyDraft={setKeyDraft}
|
||||
keyState={keyState}
|
||||
onSubmit={submitModelKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ---- Model names (live only, T-2.9) ------------------------ */}
|
||||
{liveMode && (
|
||||
<section
|
||||
aria-label="Model names"
|
||||
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
|
||||
>
|
||||
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Model names
|
||||
</h2>
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
The models your runs use — saved to your account and applied
|
||||
to every new run.
|
||||
</p>
|
||||
{userSettings.kind === "loading" && (
|
||||
<p className="mt-4 text-caption text-ink-faint">Loading…</p>
|
||||
)}
|
||||
{userSettings.kind === "error" && (
|
||||
<p role="alert" className="mt-4 text-caption text-danger">
|
||||
{userSettings.message}
|
||||
</p>
|
||||
)}
|
||||
{userReady && (
|
||||
<ModelNamesForm
|
||||
initial={userReady}
|
||||
saveState={modelsSaveState}
|
||||
onSave={saveModels}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ---- API keys --------------------------------------------- */}
|
||||
<section
|
||||
aria-label="API keys"
|
||||
@@ -154,20 +299,62 @@ export function SettingsView() {
|
||||
Local directory finished reports are written to — no upload
|
||||
quotas, plain Markdown and JSON on disk.
|
||||
</p>
|
||||
<label className="mt-4 block">
|
||||
<span className="text-small font-medium text-ink">Reports directory</span>
|
||||
<input
|
||||
type="text"
|
||||
value={storage}
|
||||
onChange={(event) => {
|
||||
setStorage(event.target.value);
|
||||
setSaveState("idle");
|
||||
}}
|
||||
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
|
||||
/>
|
||||
</label>
|
||||
{liveMode && configStatus ? (
|
||||
// D-111: server-managed, display-only in live mode — no picker.
|
||||
<p
|
||||
data-testid="storage-display"
|
||||
className="mt-4 rounded-card border border-hairline bg-paper-sunken px-3 py-2 font-mono text-small text-ink"
|
||||
>
|
||||
{configStatus.storage_dir}
|
||||
<span className="ml-2 text-caption text-ink-faint">
|
||||
(server-managed — read only)
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<label className="mt-4 block">
|
||||
<span className="text-small font-medium text-ink">Reports directory</span>
|
||||
<input
|
||||
type="text"
|
||||
value={storage}
|
||||
onChange={(event) => {
|
||||
setStorage(event.target.value);
|
||||
setSaveState("idle");
|
||||
}}
|
||||
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ---- Usage (live only, P-13) -------------------------------- */}
|
||||
{liveMode && configStatus && (
|
||||
<section
|
||||
aria-label="Usage"
|
||||
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
|
||||
>
|
||||
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Usage
|
||||
</h2>
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
Your research activity on this server.
|
||||
</p>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-4 rounded-card border border-hairline bg-paper px-4 py-4">
|
||||
<div>
|
||||
<dt className="text-caption text-ink-faint">Runs created</dt>
|
||||
<dd className="text-body font-medium text-ink">
|
||||
{configStatus.cost_counters.runs_created}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-caption text-ink-faint">Runs completed</dt>
|
||||
<dd className="text-body font-medium text-ink">
|
||||
{configStatus.cost_counters.runs_completed}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ---- Methodology defaults ---------------------------------- */}
|
||||
<section
|
||||
aria-label="Methodology defaults"
|
||||
@@ -276,6 +463,220 @@ export function SettingsView() {
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
/* Live-mode per-user subcomponents (T-2.9) */
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Model credentials (REQ-73/74): the resolved credential tier badge,
|
||||
* the masked key status, and the password-shaped key entry. The raw
|
||||
* key lives in the input only until submit; the response is masked
|
||||
* ("…1234"). A disabled key store (409) shows the honest reason.
|
||||
*/
|
||||
function ModelCredentialsSection({
|
||||
configStatus,
|
||||
keyDraft,
|
||||
onKeyDraft,
|
||||
keyState,
|
||||
onSubmit,
|
||||
}: {
|
||||
configStatus: ConfigStatus | null;
|
||||
keyDraft: string;
|
||||
onKeyDraft: (next: string) => 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 (
|
||||
<section
|
||||
aria-label="Model credentials"
|
||||
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
|
||||
>
|
||||
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Model credentials
|
||||
</h2>
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
Your key is encrypted at rest and shown masked — never displayed raw.
|
||||
</p>
|
||||
|
||||
{/* Resolved credential tier (REQ-74) — badge + honest copy. */}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span className="text-small font-medium text-ink">Credential tier</span>
|
||||
<span
|
||||
data-testid="credential-tier"
|
||||
className={`rounded-pill px-2.5 py-0.5 text-caption ${
|
||||
tier === "user"
|
||||
? "bg-success-wash text-success"
|
||||
: tier === "server"
|
||||
? "bg-accent-wash text-accent-ink"
|
||||
: "bg-warn-wash text-warn"
|
||||
}`}
|
||||
>
|
||||
{TIER_COPY[tier]}
|
||||
</span>
|
||||
<span className="text-caption text-ink-faint">
|
||||
{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."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Masked key status (REQ-73). */}
|
||||
{keyStatus && (
|
||||
<p
|
||||
data-testid="key-status"
|
||||
className="mt-3 rounded-card border border-hairline bg-paper px-4 py-3 text-caption text-ink-soft"
|
||||
>
|
||||
{keyStatus.configured ? (
|
||||
<>
|
||||
Model key stored —{" "}
|
||||
<span data-testid="key-masked" className="font-medium text-ink">
|
||||
{keyStatus.masked}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"No model key stored."
|
||||
)}
|
||||
{keyStatus.reason && (
|
||||
<span data-testid="key-reason" className="mt-1 block text-warn">
|
||||
{keyStatus.reason}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Key entry — disabled honestly when the store is off (409 class). */}
|
||||
<label className="mt-4 block">
|
||||
<span className="text-small font-medium text-ink">
|
||||
{keyStatus?.configured ? "Replace model key" : "Add model key"}
|
||||
</span>
|
||||
<div className="mt-1 flex max-w-md gap-2">
|
||||
<input
|
||||
type="password"
|
||||
// A disabled key store refuses with 409 — the input reflects
|
||||
// the honest server state instead of pretending to accept.
|
||||
disabled={!keyStoreEnabled}
|
||||
autoComplete="off"
|
||||
value={keyDraft}
|
||||
onChange={(event) => onKeyDraft(event.target.value)}
|
||||
placeholder="sk-…"
|
||||
aria-label="Model key"
|
||||
className="w-full rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!keyStoreEnabled || keyDraft.trim() === "" || keyState.kind === "saving"}
|
||||
className="shrink-0 rounded-pill bg-accent px-4 py-2 text-small font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{keyState.kind === "saving" ? "Storing…" : "Store key"}
|
||||
</button>
|
||||
</div>
|
||||
{!keyStoreEnabled && (
|
||||
<p data-testid="key-store-disabled" className="mt-2 text-caption text-warn">
|
||||
Key storage is disabled on this server —{" "}
|
||||
{keyStatus?.reason ?? "the key store is not configured."}{" "}
|
||||
Runs use the server tier or keyless localhost.
|
||||
</p>
|
||||
)}
|
||||
{keyState.kind === "saved" && (
|
||||
<p role="status" data-testid="key-saved" className="mt-2 text-caption text-success">
|
||||
Key stored — shown masked above.
|
||||
</p>
|
||||
)}
|
||||
{keyState.kind === "error" && (
|
||||
<p role="alert" data-testid="key-error" className="mt-2 text-caption text-danger">
|
||||
{keyState.message}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-small font-medium text-ink">Planner model</span>
|
||||
<input
|
||||
type="text"
|
||||
value={planner}
|
||||
onChange={(event) => {
|
||||
setPlanner(event.target.value);
|
||||
setTouched(true);
|
||||
}}
|
||||
placeholder="gpt-oss:20b"
|
||||
aria-label="Planner model"
|
||||
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-small font-medium text-ink">Writer model</span>
|
||||
<input
|
||||
type="text"
|
||||
value={writer}
|
||||
onChange={(event) => {
|
||||
setWriter(event.target.value);
|
||||
setTouched(true);
|
||||
}}
|
||||
placeholder="gpt-oss:20b"
|
||||
aria-label="Writer model"
|
||||
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saveState === "saving"}
|
||||
onClick={() => onSave(planner, writer)}
|
||||
className="rounded-pill bg-accent px-5 py-2 text-small font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{saveState === "saving" ? "Saving…" : "Save models"}
|
||||
</button>
|
||||
{saveState === "saved" && (
|
||||
<p role="status" className="text-caption text-success">
|
||||
Saved — applied to your next runs.
|
||||
</p>
|
||||
)}
|
||||
{saveState === "error" && (
|
||||
<p role="alert" className="text-caption text-danger">
|
||||
Save failed — try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved default-dial display — labels from the DIALS registry.
|
||||
* Previews the currently selected methodology (live), falling back to
|
||||
|
||||
@@ -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<Settings>): Promise<Settings>;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 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<UserSettings>;
|
||||
|
||||
/**
|
||||
* 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<UserSettings>): Promise<UserSettings>;
|
||||
|
||||
/**
|
||||
* 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<ModelKeyStatus>;
|
||||
|
||||
/**
|
||||
* 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<ConfigStatus>;
|
||||
|
||||
/**
|
||||
* Follow-up query suggestions for the New Research view —
|
||||
* derived from unanswered open questions of past runs
|
||||
|
||||
+234
-1
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>): 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<UserSettings> {
|
||||
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<string, unknown>);
|
||||
}
|
||||
|
||||
override async saveUserSettings(
|
||||
update: Partial<UserSettings>,
|
||||
): Promise<UserSettings> {
|
||||
// Partial body: only the fields the caller set travel on the wire.
|
||||
const body: Record<string, unknown> = {};
|
||||
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<string, unknown>);
|
||||
}
|
||||
|
||||
override async setModelKey(key: string): Promise<ModelKeyStatus> {
|
||||
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<string, unknown> }).detail)
|
||||
: (payload as Record<string, unknown>);
|
||||
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<string, unknown>);
|
||||
}
|
||||
|
||||
override async getConfigStatus(): Promise<ConfigStatus> {
|
||||
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<string, unknown>;
|
||||
const keyStatus = backend.key_status as Record<string, unknown> | undefined;
|
||||
const counters =
|
||||
(backend.cost_counters as Record<string, unknown> | 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<string, unknown> | null | undefined) ?? null,
|
||||
cost_counters: {
|
||||
runs_created: Number(counters.runs_created ?? 0),
|
||||
runs_completed: Number(counters.runs_completed ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
override async getMethodologies(): Promise<Methodology[]> {
|
||||
const { data, error } = await this.#client.GET("/api/methodologies");
|
||||
if (error || !data) {
|
||||
|
||||
@@ -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<UserSettings> {
|
||||
return structuredClone(this.#userSettings);
|
||||
}
|
||||
|
||||
/** Persist a partial per-user settings update (REQ-72) — in-memory. */
|
||||
async saveUserSettings(update: Partial<UserSettings>): Promise<UserSettings> {
|
||||
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<ModelKeyStatus> {
|
||||
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<ConfigStatus> {
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, RouteHandler>): 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<string, string> {
|
||||
const raw = init?.headers;
|
||||
const record: Record<string, string> = {};
|
||||
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<string, unknown>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
/** 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<string, unknown> | 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
|
||||
|
||||
Reference in New Issue
Block a user