2 Commits

Author SHA1 Message Date
coreci ccb373dee1 docs(P02): phase 2 shipped — checkpoint advances to phase 3
---ci---
phase: 2
milestone: v0.3
status: complete
requirements:
  covered: [REQ-72, REQ-73, REQ-74]
  partial: []
---/ci---

Ship record: PR #18 squash-merged phase/02-per-user-settings-keys →
milestone/v0.3-hosted (ee26ff1), tag v0.3.2, phase branch deleted
(local+remote), Gitea release v0.3.2 created. Gates green at the tag
(backend 264 passed + ruff + mypy; frontend 131 passed + tsc + build
+ drift). REQUIREMENTS traceability REQ-72..74 → complete; ROADMAP
P2 → complete, P3 → next.
2026-09-30 10:31:45 +00:00
coreci-bot ee26ff1658 feat(P02): per-user settings + model keys (#18)
Squash-merge of phase/02-per-user-settings-keys into milestone/v0.3-hosted. REQ-72/73/74 covered: encrypted key store (AES-256-GCM, AAD, masked-only, honest degradation), per-user settings, ModelAdapterFactory per-run chain (user→server→keyless), config rework (factory probe, owner-keyed counters, P-1/P-2/P-13 closures), production main entrypoint (P-3 backend half). Verify PASS-WITH-FIXES (P0 null-poisoning fixed). 264 backend + 131 frontend tests. Ships as v0.3.2.
2026-09-30 10:31:24 +00:00
25 changed files with 4387 additions and 61 deletions
+2 -2
View File
@@ -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"
}
+1 -1
View File
@@ -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
View File
@@ -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).
+1
View File
@@ -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",
]
+17 -6
View File
@@ -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
+225 -25
View File
@@ -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()
+87
View File
@@ -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()
+336
View File
@@ -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"]
+104
View File
@@ -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
+336
View File
@@ -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)
+474
View File
@@ -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
+312
View File
@@ -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"
+52
View File
@@ -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" },
+266
View File
@@ -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": {
+425 -24
View File
@@ -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
+51
View File
@@ -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
View File
@@ -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;
+142
View File
@@ -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) {
+72
View File
@@ -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
+32 -1
View File
@@ -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 });
});
});
+86
View File
@@ -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