2 Commits

Author SHA1 Message Date
coreci b8b0b68f1b docs(P01): phase 1 shipped — checkpoint advances to phase 2
---ci---
phase: 1
milestone: v0.3
status: complete
requirements:
  covered: [REQ-69, REQ-70, REQ-71]
  partial: []
---/ci---

Ship record: PR #17 squash-merged phase/01-auth-sessions-scoping →
milestone/v0.3-hosted (2a61433), tag v0.3.1, phase branch deleted
(local+remote), Gitea release v0.3.1 created. Gates green at the tag
(backend 184 passed + ruff + mypy; frontend 119 passed + tsc + build
+ drift gate). REQUIREMENTS traceability REQ-69..71 → complete;
ROADMAP P1 → complete, P2 → next.
2026-09-29 21:36:50 +00:00
coreci-bot 2a61433e6c feat(P01): auth + sessions + user scoping (#17)
Squash-merge of phase/01-auth-sessions-scoping into milestone/v0.3-hosted. REQ-69/70/71 covered. Auth package (argon2id users, server-side sessions, guard+CSRF, admin routes), user-scoped storage partitioning, owner-keyed registry, restart sweep (G-101), registry pruning, frontend session surface (login, gate, user menu, ApiClient extensions), openapi snapshot regen + drift gate. Security: P0 register race + P1 corrupt-store fail-closed fixed in-phase. Verify: PASS 4/4 layers, 184+119 tests. Ships as v0.3.1.
2026-09-29 21:36:31 +00:00
58 changed files with 4617 additions and 175 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
{
"phase": 0,
"phase": 1,
"stage": "complete",
"milestone": "v0.3",
"phase_role": "pre_execution",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-09-29T17:25:00Z"
"updated_at": "2026-09-29T21:40:00Z"
}
+1 -1
View File
@@ -161,7 +161,7 @@
| REQ-25, REQ-26, REQ-33 (API, client, tooling) | v0.2 | P3 | complete (v0.2.3) |
| 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 | pending |
| 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-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 |
+1 -1
View File
@@ -145,7 +145,7 @@ Scope (owner session 2026-09-29): one milestone combining the G-009-ratified liv
- **Requirements covered**: REQ-69, REQ-70, REQ-71
- **Success criteria**: unauthenticated API access returns 401; two users cannot see each other's runs; admin can create/deactivate accounts; existing engine tests green under an authenticated harness
- **Depends on**: 0
- **Status**: pending
- **Status**: complete (PR #17 squash-merged to milestone/v0.3-hosted 2a61433, tag v0.3.1, release HTTP 201, phase branch deleted; verify PASS 4/4 — 184 backend + 119 frontend tests; security P0 register race + P1 corrupt-store fail-closed fixed in-phase; D-116 rate-limit decision recorded)
### Phase 2 — Per-User Settings + Model Keys
- **Description**: Encrypted per-user key store (KEK from env-file secrets scope), per-user settings persistence (PUT/GET /api/config), per-run credential resolution chain (user key → server key → keyless localhost; REQ-74) with probe per resolved key, settings UI live-bound with masked keys.
+2 -1
View File
@@ -2,4 +2,5 @@
__pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.mypy_cache/
var/
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"uvicorn>=0.30,<1",
"sse-starlette>=2.1,<3",
"httpx>=0.27,<1",
"argon2-cffi>=23.1,<26",
]
[dependency-groups]
@@ -5,19 +5,27 @@ import secrets as secrets_module
from pathlib import Path
from researchx.adapters.types import AdapterError
from researchx.config.ids import SLUG_RE, generate_slug
_RUN_ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"
_RUN_ID_RE = re.compile(r"^[a-z0-9]{8}$")
_ARTIFACT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def generate_run_id() -> str:
return "".join(secrets_module.choice(_RUN_ID_ALPHABET) for _ in range(8))
"""Generate an 8-char run id (consolidated generator — config/ids.py, P-14)."""
return generate_slug()
def _validate_slug(value: str, what: str) -> None:
if SLUG_RE.match(value) is None:
raise AdapterError(f"invalid {what}")
def _validate_run_id(run_id: str) -> None:
if not _RUN_ID_RE.match(run_id):
raise AdapterError("invalid run id")
_validate_slug(run_id, "run id")
def _validate_owner_id(owner_id: str) -> None:
_validate_slug(owner_id, "owner id")
def _validate_artifact_name(name: str) -> None:
@@ -26,12 +34,41 @@ def _validate_artifact_name(name: str) -> None:
class LocalStorageAdapter:
def __init__(self, base_dir: Path) -> None:
"""Owner-scoped local run storage (D-106): runs/<owner_id>/<run_id>/.
``owner_id`` is REQUIRED (an optional default invites silent
unpartitioned use — RESEARCH §5 pitfall) and validated against the
shared 8-char slug shape (D-105) so it is a filesystem-safe
partition key.
"""
def __init__(self, base_dir: Path, owner_id: str) -> None:
_validate_owner_id(owner_id)
self._base_dir = base_dir
self._owner_id = owner_id
@property
def owner_id(self) -> str:
return self._owner_id
@property
def base_dir(self) -> Path:
return self._base_dir
def run_dir(self, run_id: str) -> Path:
_validate_run_id(run_id)
return self._base_dir / "runs" / run_id
return self._base_dir / "runs" / self._owner_id / run_id
def list_run_ids(self) -> list[str]:
"""Scan this owner's partition, skipping non-directories/dotfiles."""
runs_dir = self._base_dir / "runs" / self._owner_id
if not runs_dir.is_dir():
return []
return sorted(
child.name
for child in runs_dir.iterdir()
if child.is_dir() and not child.name.startswith(".")
)
async def write_run_artifact(self, run_id: str, name: str, content: str) -> Path:
_validate_artifact_name(name)
@@ -58,7 +95,9 @@ class LocalStorageAdapter:
try:
return target.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise AdapterError(f"artifact not found: runs/{run_id}/{name}") from exc
raise AdapterError(
f"artifact not found: runs/{self._owner_id}/{run_id}/{name}"
) from exc
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path:
_validate_artifact_name(name)
@@ -8,4 +8,6 @@ class StorageAdapter(Protocol):
async def read_run_artifact(self, run_id: str, name: str) -> str: ...
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path: ...
def run_dir(self, run_id: str) -> Path: ...
+253 -93
View File
@@ -1,9 +1,9 @@
import asyncio
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from typing import Annotated, Any
from fastapi import FastAPI, HTTPException
from fastapi import APIRouter, Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, ValidationError
from sse_starlette.sse import EventSourceResponse
@@ -12,10 +12,17 @@ from researchx.adapters.capability_probe import probe_model_adapter, supports_pr
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
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.models import UserPublic
from researchx.auth.routes import build_auth_router
from researchx.auth.sessions import SessionStore
from researchx.auth.users import UserStore
from researchx.config.paths import data_root
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus, RunEvent
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
from researchx.engine.states import TERMINAL_STATES, RunStateName
from researchx.methodologies import registry
_CANNED_PLAN = (
@@ -27,6 +34,8 @@ _CANNED_WRITER = (
"The verified core finding [p-1]. A second corroborated observation [p-2].\n"
)
_CORS_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]
class CreateRunBody(BaseModel):
question: str = Field(min_length=1, max_length=2000)
@@ -84,11 +93,8 @@ _ETA_TABLE = {"rapid-answer": (2, "low"), "deep-report": (12, "medium")}
def _cost_estimate(slug: str) -> str:
depth, breadth = 1, 1
methodology = registry.get(slug)
depth = methodology.dials.depth
breadth = methodology.dials.breadth
units = depth * breadth
units = methodology.dials.depth * methodology.dials.breadth
if units <= 1:
return "~5k tokens (estimate)"
if units <= 4:
@@ -104,33 +110,108 @@ def _why_recommended(slug: str) -> str:
class RunRegistry:
"""In-memory live-run registry, keyed (owner_id, run_id) (REQ-71).
Cross-owner access is indistinguishable from a missing run: the
keyed lookup misses, routes translate the miss to a 404. Single
uvicorn worker only — this state is single-process by design.
"""
def __init__(self) -> None:
self._actors: dict[str, tuple[RunActor, asyncio.Task[RunState]]] = {}
self._buses: dict[str, EventBus] = {}
self.cost_counters: dict[str, int] = {"runs_created": 0, "runs_completed": 0}
self._actors: dict[tuple[str, str], tuple[RunActor, asyncio.Task[RunState]]] = {}
self._buses: dict[tuple[str, str], EventBus] = {}
# Live SSE subscriber refcounts, keyed like _actors (P-6).
self._subscribers: dict[tuple[str, str], int] = {}
# Owner-keyed cost counters (P-13): per-user usage surface.
self.cost_counters: dict[str, dict[str, int]] = {}
def add(self, run_id: str, actor: RunActor, task: asyncio.Task[RunState], bus: EventBus) -> None:
self._actors[run_id] = (actor, task)
self._buses[run_id] = bus
self.cost_counters["runs_created"] += 1
def _counters(self, owner_id: str) -> dict[str, int]:
return self.cost_counters.setdefault(owner_id, {"runs_created": 0, "runs_completed": 0})
def actor(self, run_id: str) -> RunActor:
entry = self._actors.get(run_id)
def add(
self,
owner_id: str,
run_id: str,
actor: RunActor,
task: asyncio.Task[RunState],
bus: EventBus,
) -> None:
self._actors[(owner_id, run_id)] = (actor, task)
self._buses[(owner_id, run_id)] = bus
self._counters(owner_id)["runs_created"] += 1
def actor(self, owner_id: str, run_id: str) -> RunActor:
entry = self._actors.get((owner_id, run_id))
if entry is None:
raise KeyError(run_id)
raise KeyError((owner_id, run_id))
return entry[0]
def bus(self, run_id: str) -> EventBus:
bus = self._buses.get(run_id)
def bus(self, owner_id: str, run_id: str) -> EventBus:
bus = self._buses.get((owner_id, run_id))
if bus is None:
raise KeyError(run_id)
raise KeyError((owner_id, run_id))
return bus
def has(self, run_id: str) -> bool:
return run_id in self._actors
def has(self, owner_id: str, run_id: str) -> bool:
return (owner_id, run_id) in self._actors
def note_completed(self) -> None:
self.cost_counters["runs_completed"] += 1
def note_completed(self, owner_id: str) -> None:
self._counters(owner_id)["runs_completed"] += 1
def completed_snapshot(self, owner_id: str) -> dict[str, int]:
"""Current counters for *owner_id*, derived where possible.
``runs_completed`` is computed from the live registry (a run is
completed iff its actor sits in a terminal state) and merged
with the sticky counter — which keeps counting runs that have
since been pruned from the registry. Deriving from actor state
removes the scheduling race between the actor finishing and a
detached watcher task incrementing the counter.
"""
counters = dict(self._counters(owner_id))
live_done = sum(
1
for (owner, _), (actor, _) in self._actors.items()
if owner == owner_id and actor.state.state == RunStateName.DONE
)
if live_done:
# Prefer live truth when registry entries still exist; the
# sticky counter catches pruned/evicted runs afterwards.
counters["runs_completed"] = max(counters["runs_completed"], live_done)
return counters
def subscribe_events(self, owner_id: str, run_id: str) -> None:
self._subscribers[(owner_id, run_id)] = self._subscribers.get((owner_id, run_id), 0) + 1
def unsubscribe_events(self, owner_id: str, run_id: str) -> None:
count = self._subscribers.get((owner_id, run_id), 0)
if count <= 1:
self._subscribers.pop((owner_id, run_id), None)
else:
self._subscribers[(owner_id, run_id)] = count - 1
def subscriber_count(self, owner_id: str, run_id: str) -> int:
return self._subscribers.get((owner_id, run_id), 0)
def prune(self, owner_id: str, run_id: str) -> bool:
"""Drop a terminal entry with zero live SSE subscribers (P-6).
Terminal entries are otherwise a slow multi-user leak. Events
for a pruned run 404 afterwards — documented acceptable; the
trace persists on disk.
"""
entry = self._actors.get((owner_id, run_id))
if entry is None:
return False
actor = entry[0]
if actor.state.state not in TERMINAL_STATES:
return False
if self.subscriber_count(owner_id, run_id) > 0:
return False
self._actors.pop((owner_id, run_id), None)
self._buses.pop((owner_id, run_id), None)
self._subscribers.pop((owner_id, run_id), None)
return True
def create_app(
@@ -138,16 +219,23 @@ def create_app(
storage_dir: Path | None = None,
model_adapter: Any = None,
search_adapter: Any = None,
auth_enabled: bool = True,
cookie_secure: bool = False,
data_dir: Path | None = None,
) -> FastAPI:
app = FastAPI(title="ResearchX API", version="0.2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
"""Build the ResearchX API app.
storage = LocalStorageAdapter(base_dir=storage_dir or Path.cwd() / ".researchx")
``auth_enabled=False`` is a dev/test harness parameter ONLY — never
an env var readable in production (C3-16). It resolves a synthetic
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.
"""
app = FastAPI(title="ResearchX API", version="0.2.0")
# D-107: run artifacts live under the data root (storage_dir
# 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=[
@@ -157,8 +245,20 @@ def create_app(
)
search = search_adapter or FakeSearchAdapter(CORPUS)
def _summary(run_id: str) -> RunSummary:
actor = runs.actor(run_id)
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")
guard: Guard = build_guard(users, sessions) if auth_enabled else build_dev_guard()
admin_guard = build_admin_guard(guard)
def _owner_storage(user: UserPublic) -> LocalStorageAdapter:
"""Owner-scoped storage, constructed per request from the auth
dependency (REQ-71): runs/<user_id>/<run_id>/."""
return LocalStorageAdapter(base_dir=storage_base, owner_id=user.user_id)
def _summary(user: UserPublic, run_id: str) -> RunSummary:
actor = runs.actor(user.user_id, run_id)
state = actor.state
return RunSummary(
id=run_id,
@@ -169,21 +269,41 @@ def create_app(
started_at=state.created_at.isoformat(),
)
@app.get("/api/config")
async def config_status() -> ConfigStatus:
# ------------------------------------------------------------------
# PUBLIC surface: /healthz outside /api (D-103 — plain GET for the
# TLS-front proxy, never auth-wrapped) + /api/auth/* (no
# require_user; the auth router enforces the CSRF header on its
# own POSTs).
# ------------------------------------------------------------------
public = build_auth_router(users, sessions, cookie_secure=cookie_secure)
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
# ------------------------------------------------------------------
# PROTECTED router: every /api business route carries the guard.
# Admin user management additionally carries the admin guard.
# ------------------------------------------------------------------
protected = APIRouter(dependencies=[Depends(guard)])
protected.include_router(build_admin_router(users, sessions, admin_guard))
@protected.get("/api/config")
async def config_status(user: Annotated[UserPublic, Depends(guard)]) -> ConfigStatus:
latest_probe = None
for run_id in list(runs._actors)[-1:]:
actor_probe = runs.actor(run_id).state.probe
latest_probe = actor_probe
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
return ConfigStatus(
model_adapter=type(model).__name__,
search_adapter=type(search).__name__,
storage_dir=str(storage.run_dir("00000000").parent),
storage_dir=str(storage_base),
probe=latest_probe,
cost_counters=runs.cost_counters,
cost_counters=runs.completed_snapshot(user.user_id),
)
@app.get("/api/methodologies")
@protected.get("/api/methodologies")
async def methodologies() -> list[MethodologyCard]:
cards: list[MethodologyCard] = []
for methodology in registry.list():
@@ -202,10 +322,11 @@ def create_app(
)
return cards
@app.post("/api/runs", status_code=201)
async def create_run(body: CreateRunBody) -> RunSummary:
@protected.post("/api/runs", status_code=201)
async def create_run(body: CreateRunBody, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
if body.methodology not in registry:
raise HTTPException(status_code=404, detail="unknown methodology")
storage = _owner_storage(user)
try:
run_id, config = make_run_config(
question=body.question,
@@ -213,6 +334,7 @@ def create_app(
methodology_slug=body.methodology,
overrides=body.dial_overrides,
registry=registry,
owner_id=user.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@@ -227,37 +349,42 @@ def create_app(
registry=registry,
event_bus=bus,
)
probe_data: dict[str, Any] | None = None
# 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):
try:
probe = await probe_model_adapter(model)
probe_data = probe.model_dump(mode="json")
await actor.attach_probe(probe_data)
await actor.attach_probe(probe.model_dump(mode="json"))
except AdapterError:
probe_data = None
pass
task = asyncio.create_task(actor.run())
runs.add(run_id, actor, task, bus)
runs.add(user.user_id, run_id, actor, task, bus)
async def _watch() -> None:
state = await task
if state.state == RunStateName.DONE:
runs.note_completed()
runs.note_completed(user.user_id)
asyncio.create_task(_watch())
return _summary(run_id)
return _summary(user, run_id)
@app.get("/api/runs/{run_id}")
async def get_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
@protected.get("/api/runs/{run_id}")
async def get_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
return _summary(run_id)
return _summary(user, run_id)
@app.post("/api/runs/{run_id}/approve")
async def approve_run(run_id: str, body: ApproveBody | None = None) -> RunSummary:
if not runs.has(run_id):
@protected.post("/api/runs/{run_id}/approve")
async def approve_run(
run_id: str,
user: Annotated[UserPublic, Depends(guard)],
body: ApproveBody | None = None,
) -> RunSummary:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
actor = runs.actor(run_id)
actor = runs.actor(user.user_id, run_id)
edited_plan: Plan | None = None
if body and body.plan:
try:
@@ -265,38 +392,40 @@ def create_app(
except ValidationError:
raise HTTPException(status_code=422, detail="invalid plan")
await actor.approve(edited_plan)
return _summary(run_id)
return _summary(user, run_id)
@app.post("/api/runs/{run_id}/pause")
async def pause_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
@protected.post("/api/runs/{run_id}/pause")
async def pause_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
runs.actor(run_id).pause()
return _summary(run_id)
runs.actor(user.user_id, run_id).pause()
return _summary(user, run_id)
@app.post("/api/runs/{run_id}/resume")
async def resume_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
@protected.post("/api/runs/{run_id}/resume")
async def resume_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
runs.actor(run_id).resume()
return _summary(run_id)
runs.actor(user.user_id, run_id).resume()
return _summary(user, run_id)
@app.post("/api/runs/{run_id}/cancel")
async def cancel_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
@protected.post("/api/runs/{run_id}/cancel")
async def cancel_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
actor = runs.actor(run_id)
actor = runs.actor(user.user_id, run_id)
actor.cancel()
actor.resume()
return _summary(run_id)
return _summary(user, run_id)
@app.get("/api/runs/{run_id}/events")
async def run_events(run_id: str) -> EventSourceResponse:
if not runs.has(run_id):
@protected.get("/api/runs/{run_id}/events")
async def run_events(
run_id: str, user: Annotated[UserPublic, Depends(guard)]
) -> EventSourceResponse:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
bus = runs.bus(run_id)
actor = runs.actor(run_id)
terminal = {RunStateName.DONE, RunStateName.CANCELLED, RunStateName.FAILED}
bus = runs.bus(user.user_id, run_id)
actor = runs.actor(user.user_id, run_id)
terminal = TERMINAL_STATES
async def event_stream() -> AsyncIterator[dict[str, str]]:
queue: asyncio.Queue[RunEvent] = asyncio.Queue()
@@ -304,6 +433,7 @@ def create_app(
async def forward(event: RunEvent) -> None:
await queue.put(event)
runs.subscribe_events(user.user_id, run_id) # SSE refcount (P-6)
bus.subscribe(forward)
try:
replay = bus.replay()
@@ -325,27 +455,31 @@ def create_app(
return
finally:
bus.unsubscribe(forward)
runs.unsubscribe_events(user.user_id, run_id)
# Registry pruning (P-6): a terminal run whose last SSE
# subscriber just left is dropped from the registry.
# Events for a pruned run 404 afterwards — documented
# acceptable; the trace persists on disk.
runs.prune(user.user_id, run_id)
return EventSourceResponse(event_stream())
return EventSourceResponse(event_stream())
@app.get("/api/runs/{run_id}/report")
async def run_report(run_id: str) -> dict[str, Any]:
if not runs.has(run_id):
@protected.get("/api/runs/{run_id}/report")
async def run_report(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
try:
content = await storage.read_run_artifact(run_id, "report.md")
content = await _owner_storage(user).read_run_artifact(run_id, "report.md")
except AdapterError:
raise HTTPException(status_code=404, detail="report not ready")
return {"run_id": run_id, "markdown": content}
@app.get("/api/runs/{run_id}/ledger")
async def run_ledger(run_id: str) -> dict[str, Any]:
if not runs.has(run_id):
@protected.get("/api/runs/{run_id}/ledger")
async def run_ledger(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
try:
content = await storage.read_run_artifact(run_id, "ledger.json")
content = await _owner_storage(user).read_run_artifact(run_id, "ledger.json")
except AdapterError:
raise HTTPException(status_code=404, detail="ledger not ready")
import json
@@ -353,7 +487,33 @@ def create_app(
data: dict[str, Any] = json.loads(content)
return data
app.include_router(public)
app.include_router(protected)
# Startup sweep (D-115, G-101): every non-terminal state.json under
# the storage base is rewritten as CANCELLED — in-flight actors died
# with the previous process; rehydration is out of v0.3 scope. Runs
# synchronously at app construction so the very first request
# already sees a consistent disk.
from researchx.api.sweep import sweep_in_flight_runs
sweep_in_flight_runs(storage_base)
# CORS is added AFTER the routers so it is the OUTERMOST middleware
# (starlette builds the stack in add-order: last added = outermost)
# — preflight OPTIONS is answered without any auth 401 (P-4/C3-04).
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
return app
app = create_app()
# 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()
+6
View File
@@ -64,10 +64,16 @@ class EndlessModelAdapter:
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
# Dev/E2E bridge: auth explicitly OFF (P-5) — a loopback-only,
# disposable harness. Never the production entrypoint; the
# production server (researchx.api.main, Phase 2) never passes
# auth_enabled.
app = create_app(
storage_dir=Path(tmp),
model_adapter=EndlessModelAdapter(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=Path(tmp),
)
@app.on_event("startup")
+61
View File
@@ -0,0 +1,61 @@
"""Startup sweep (D-115, G-101).
On app construction, every non-terminal ``state.json`` under the
storage base is rewritten as CANCELLED — in-flight actors died with the
previous process; rehydration is out of v0.3 scope. CANCELLED is legal
from every non-terminal state (states.py — including PLANNING per
G-101, pinned by test). The registry-side pruning lives in app.py
(RunRegistry.prune, P-6).
"""
from pathlib import Path
from researchx.engine.run_state import RunState
from researchx.engine.states import TERMINAL_STATES, RunStateName
def _atomic_write(path: Path, content: str) -> None:
import os
import secrets
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(content)
handle.flush()
os.fsync(handle.fileno())
tmp_path.replace(path)
def sweep_in_flight_runs(base_dir: Path) -> list[tuple[str, str]]:
"""Rewrite every non-terminal state.json under base_dir as CANCELLED.
Scans ``base_dir/runs/<owner_id>/<run_id>/state.json`` (the
partitioned layout, D-106). Returns the swept (owner_id, run_id)
pairs. Corrupt state files are skipped defensively — a sweep must
never prevent startup.
"""
swept: list[tuple[str, str]] = []
runs_dir = base_dir / "runs"
if not runs_dir.is_dir():
return swept
for owner_dir in sorted(runs_dir.iterdir()):
if not owner_dir.is_dir() or owner_dir.name.startswith("."):
continue
for run_dir in sorted(owner_dir.iterdir()):
if not run_dir.is_dir() or run_dir.name.startswith("."):
continue
state_path = run_dir / "state.json"
if not state_path.is_file():
continue
try:
state = RunState.model_validate_json(state_path.read_text(encoding="utf-8"))
except (ValueError, OSError):
# Corrupt/unreadable state files are not swept, never
# fatal — a sweep must never prevent startup.
continue
if state.state in TERMINAL_STATES:
continue
state.state = RunStateName.CANCELLED # legal from every non-terminal state (G-101)
_atomic_write(state_path, state.model_dump_json())
swept.append((owner_dir.name, run_dir.name))
return swept
+24
View File
@@ -0,0 +1,24 @@
"""Auth package: user accounts, sessions, guard, and routes (REQ-69/70/71)."""
from researchx.auth.models import (
AdminCreateUserBody,
LoginBody,
RegisterBody,
UserPublic,
to_public,
)
from researchx.auth.sessions import Session, SessionStore
from researchx.auth.users import User, UserError, UserStore
__all__ = [
"AdminCreateUserBody",
"LoginBody",
"RegisterBody",
"Session",
"SessionStore",
"User",
"UserError",
"UserPublic",
"UserStore",
"to_public",
]
@@ -0,0 +1,56 @@
"""Admin user-management routes (T-1.5, REQ-70).
Mounted on the PROTECTED router (session + CSRF already enforced by
``require_user``); every route additionally requires the admin role.
Responses expose ``UserPublic`` only — never a password hash. Account
creation enforces the same >=12 password policy as registration;
deactivation sets ``active=False`` and immediately revokes every
session of the target user (data is retained).
"""
from fastapi import APIRouter, Depends, HTTPException
from researchx.auth.guard import Guard
from researchx.auth.models import AdminCreateUserBody, UserPublic, to_public
from researchx.auth.sessions import SessionStore
from researchx.auth.users import UserError, UserStore
def build_admin_router(
users: UserStore,
sessions: SessionStore,
admin_guard: Guard,
) -> APIRouter:
"""Build the admin user surface (mounted at /api/admin, admin-only)."""
router = APIRouter(
prefix="/api/admin",
tags=["admin"],
dependencies=[Depends(admin_guard)],
)
@router.get("/users", response_model=list[UserPublic])
async def list_users() -> list[UserPublic]:
return [to_public(u) for u in users.list()]
@router.post("/users", status_code=201, response_model=UserPublic)
async def create_user(body: AdminCreateUserBody) -> UserPublic:
try:
user = await users.create_user(
email=body.email,
password=body.password,
display_name=body.display_name or body.email.split("@")[0],
role=body.role,
)
except UserError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return to_public(user)
@router.post("/users/{user_id}/deactivate", response_model=UserPublic)
async def deactivate_user(user_id: str) -> UserPublic:
updated = users.deactivate(user_id)
if updated is None:
raise HTTPException(status_code=404, detail="user not found")
sessions.revoke_all_for(user_id) # deactivation kills sessions immediately
return to_public(updated)
return router
+109
View File
@@ -0,0 +1,109 @@
"""Auth guard — session + CSRF enforcement as route dependencies (T-1.4).
Deliberately NOT middleware: router dependencies appear per-route, are
overridable via ``app.dependency_overrides``, and never wrap SSE
response bodies (no BaseHTTPMiddleware near the event stream).
Enforcement order:
1. missing/invalid/expired session -> 401 "authentication required"
2. non-GET without the CSRF header -> 403 "missing request header"
3. missing or deactivated user -> 401 "account disabled"
then the sliding-expiry ``touch()``.
When auth is disabled (dev/test harness only — a create_app parameter,
never an env var readable in production, C3-16), the dev guard
resolves a synthetic admin user for every request so all routes behave
exactly as authenticated.
"""
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from fastapi import HTTPException, Request
from researchx.auth.models import UserPublic, to_public
from researchx.auth.sessions import Session, SessionStore
from researchx.auth.users import User, UserStore
SESSION_COOKIE = "researchx_session"
CSRF_HEADER = "x-researchx-request"
DEV_USER_ID = "dev00000"
Guard = Callable[[Request], Awaitable[UserPublic]]
def _dev_user() -> User:
"""The synthetic admin every request resolves when auth is disabled."""
return User(
user_id=DEV_USER_ID,
email="dev@localhost",
password_hash="", # never persisted, never verified
display_name="Dev User",
role="admin",
active=True,
created_at=datetime.now(UTC),
)
def require_csrf_header(request: Request) -> None:
"""CSRF defense for public POSTs (login/register) — same rule as the guard."""
if request.method != "GET" and CSRF_HEADER not in request.headers:
raise HTTPException(status_code=403, detail="missing request header")
def build_guard(users: UserStore, sessions: SessionStore) -> Guard:
"""Build the authenticated-mode require_user dependency."""
async def require_user(request: Request) -> UserPublic:
token = request.cookies.get(SESSION_COOKIE)
session: Session | None = sessions.get(token) if token else None
if session is None:
raise HTTPException(status_code=401, detail="authentication required")
if request.method != "GET" and CSRF_HEADER not in request.headers:
raise HTTPException(status_code=403, detail="missing request header")
user: User | None = users.get(session.user_id)
if user is None or not user.active:
raise HTTPException(status_code=401, detail="account disabled")
sessions.touch(session) # sliding expiry
return to_public(user)
return require_user
def build_dev_guard() -> Guard:
"""Build the dev-mode guard: every request resolves the synthetic dev user."""
async def require_user(request: Request) -> UserPublic:
del request # dev mode: no session, no CSRF — routes behave as before
return to_public(_dev_user())
return require_user
def build_admin_guard(guard: Guard) -> Guard:
"""Wrap a guard with the admin role check (REQ-70).
The returned dependency resolves the wrapped guard first, so
``app.dependency_overrides`` on either composes correctly.
"""
async def require_admin(request: Request) -> UserPublic:
user = await guard(request)
if user.role != "admin":
raise HTTPException(status_code=403, detail="admin role required")
return user
return require_admin
__all__ = [
"CSRF_HEADER",
"DEV_USER_ID",
"SESSION_COOKIE",
"Guard",
"build_admin_guard",
"build_dev_guard",
"build_guard",
"require_csrf_header",
]
+60
View File
@@ -0,0 +1,60 @@
"""Auth API request/response contracts (T-1.3).
These are the wire models for the register/login/logout/me routes and
the admin user surface. Password hashes never appear in any of them
(REQ-69: no password-hash material in any API response).
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from researchx.auth.users import MIN_PASSWORD_LENGTH, User
# Lightweight email shape check — the auth surface needs a stable,
# normalizable identifier, not a full RFC 5322 validation (and pulling
# pydantic[email] would add an unplanned dependency).
_EMAIL_RE = r"^[^\s@]+@[^\s@]+\.[^\s@]+$"
class RegisterBody(BaseModel):
email: str = Field(pattern=_EMAIL_RE, max_length=254)
password: str = Field(min_length=MIN_PASSWORD_LENGTH)
display_name: str = Field(default="", max_length=120)
class LoginBody(BaseModel):
email: str = Field(pattern=_EMAIL_RE, max_length=254)
password: str = Field(min_length=1)
class AdminCreateUserBody(BaseModel):
email: str = Field(pattern=_EMAIL_RE, max_length=254)
password: str = Field(min_length=MIN_PASSWORD_LENGTH)
display_name: str = Field(default="", max_length=120)
role: str = Field(default="user", pattern=r"^(admin|user)$")
class UserPublic(BaseModel):
"""A user as exposed on the wire — no password_hash, ever."""
model_config = ConfigDict(from_attributes=True)
user_id: str
email: str
display_name: str
role: str
active: bool
created_at: datetime
def to_public(user: User) -> UserPublic:
"""Project a stored ``User`` to its public wire shape."""
return UserPublic(
user_id=user.user_id,
email=user.email,
display_name=user.display_name,
role=user.role,
active=user.active,
created_at=user.created_at,
)
+119
View File
@@ -0,0 +1,119 @@
"""Auth routes: register / login / logout / me (T-1.4, REQ-69/70).
Mounted on a PUBLIC router at /api/auth/* (no require_user dependency)
— but login/register POSTs still enforce the CSRF header (C3-05) via
``Depends(require_csrf_header)``.
Route semantics:
- register: open only while the user store is empty; the first user
becomes admin, everything else 403s. The only await between the
emptiness check and the insert is the argon2 hash; re-checking under
the store lock keeps it race-free (D-104, single worker by design).
- login: uniform 401 "invalid email or password" for every failure
mode (no user enumeration); a fresh token is issued every login
(session-fixation defense by rotation); the httpOnly cookie is set
with SameSite=Lax, Path=/, no Domain attribute, Secure when
configured for TLS-fronted hosts.
- logout: revokes the presented token immediately.
- me: the current user (session only — no CSRF header on GETs).
"""
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from researchx.auth.guard import SESSION_COOKIE, require_csrf_header
from researchx.auth.models import LoginBody, RegisterBody, UserPublic, to_public
from researchx.auth.sessions import SessionStore
from researchx.auth.users import UserError, UserStore
_COOKIE_MAX_AGE = 14 * 24 * 3600
def build_auth_router(
users: UserStore,
sessions: SessionStore,
cookie_secure: bool,
) -> APIRouter:
"""Build the public auth router (mounted at /api/auth)."""
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=SESSION_COOKIE,
value=token,
max_age=_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
path="/",
secure=cookie_secure,
)
def _presented_token(request: Request) -> str | None:
return request.cookies.get(SESSION_COOKIE)
@router.post(
"/register",
status_code=201,
response_model=UserPublic,
dependencies=[Depends(require_csrf_header)],
)
async def register(body: RegisterBody) -> UserPublic:
# Fast-path 403 for the common case; the authoritative
# emptiness re-check happens under the store lock inside
# create_first_admin (D-104) so a concurrent duplicate
# register cannot both succeed.
if not users.empty():
raise HTTPException(status_code=403, detail="registration closed")
try:
user = await users.create_first_admin(
email=body.email,
password=body.password,
display_name=body.display_name or body.email.split("@")[0],
)
except UserError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
return to_public(user)
@router.post(
"/login",
response_model=UserPublic,
dependencies=[Depends(require_csrf_header)],
)
async def login(body: LoginBody, response: Response) -> UserPublic:
user = users.get_by_email(body.email)
if user is None or not await users.verify_password(user, body.password):
raise HTTPException(status_code=401, detail="invalid email or password")
if not user.active:
raise HTTPException(status_code=401, detail="invalid email or password")
# Token rotation: a fresh token on every login kills session
# fixation by construction (D-101).
token = sessions.generate_token()
sessions.create(token, user.user_id)
_set_session_cookie(response, token)
if await users.needs_rehash(user):
await users.rehash_password(user, body.password)
return to_public(user)
@router.post(
"/logout",
dependencies=[Depends(require_csrf_header)],
)
async def logout(request: Request, response: Response) -> dict[str, bool]:
token = _presented_token(request)
if token:
sessions.revoke(token)
response.delete_cookie(key=SESSION_COOKIE, path="/")
return {"ok": True}
@router.get("/me", response_model=UserPublic)
async def me(request: Request) -> UserPublic:
token = _presented_token(request)
session = sessions.get(token) if token else None
if session is None:
raise HTTPException(status_code=401, detail="authentication required")
user = users.get(session.user_id)
if user is None or not user.active:
raise HTTPException(status_code=401, detail="authentication required")
sessions.touch(session)
return to_public(user)
return router
+161
View File
@@ -0,0 +1,161 @@
"""Session store — server-side, opaque tokens, durable JSON mirror (D-101).
Tokens are ``secrets.token_urlsafe(32)``; only their sha256 hash is
stored (in memory and in ``<data_root>/sessions.json``). Expiry is
14-day sliding: every authenticated request calls ``touch()`` which
pushes ``expires_at`` forward, persisting at most once per 60s.
Revocation is immediate (logout deletes the presented token;
deactivation deletes every token of the user).
"""
import asyncio
import hashlib
import json
import os
import secrets
import threading
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field, ValidationError
SESSION_TTL_SECONDS = 14 * 24 * 3600
_PERSIST_THROTTLE_SECONDS = 60.0
class Session(BaseModel):
token_hash: str
user_id: str
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
last_seen: datetime = Field(default_factory=lambda: datetime.now(UTC))
expires_at: datetime
model_config = ConfigDict(frozen=True)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
class SessionStore:
"""In-memory map keyed by token_hash + durable sessions.json mirror."""
def __init__(self, path: Path) -> None:
self._path = path
self._sessions: dict[str, Session] = {}
self._lock = threading.Lock()
self._last_persist = 0.0
self._load()
# ------------------------------------------------------------ persistence
def _load(self) -> None:
if not self._path.exists():
return
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return
for record in raw.get("sessions", []):
try:
session = Session.model_validate(record)
except (ValidationError, KeyError, TypeError):
continue # defensive: skip corrupt records on load
self._sessions[session.token_hash] = session
def _persist_locked(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"sessions": [s.model_dump(mode="json") for s in self._sessions.values()]
}
tmp_path = self._path.with_name(f"{self._path.name}.{secrets.token_hex(4)}.tmp")
content = json.dumps(payload, indent=2)
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, self._path)
self._last_persist = time.monotonic()
def _persist(self) -> None:
with self._lock:
self._persist_locked()
# ------------------------------------------------------------- queries
def get(self, token: str) -> Session | None:
"""Resolve a presented token; expired or unknown tokens yield None."""
token_hash = _hash_token(token)
session = self._sessions.get(token_hash)
if session is None:
return None
if session.expires_at <= datetime.now(UTC):
with self._lock:
expired = self._sessions.pop(token_hash, None)
if expired is not None:
self._persist_locked()
return None
return session
# ------------------------------------------------------------- mutation
def create(self, token: str, user_id: str) -> Session:
"""Store a new session for *token* (only its hash is kept)."""
now = datetime.now(UTC)
session = Session(
token_hash=_hash_token(token),
user_id=user_id,
created_at=now,
last_seen=now,
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
)
with self._lock:
self._sessions[session.token_hash] = session
self._persist_locked()
return session
def touch(self, session: Session) -> None:
"""Sliding expiry: push expires_at forward, throttled persist."""
now = datetime.now(UTC)
renewed = Session(
token_hash=session.token_hash,
user_id=session.user_id,
created_at=session.created_at,
last_seen=now,
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
)
with self._lock:
current = self._sessions.get(session.token_hash)
if current is None or current != session:
return # stale or raced touch — nothing to extend
self._sessions[renewed.token_hash] = renewed
if time.monotonic() - self._last_persist > _PERSIST_THROTTLE_SECONDS:
self._persist_locked()
def revoke(self, token: str) -> None:
"""Immediately delete the session for *token* (logout)."""
token_hash = _hash_token(token)
with self._lock:
if self._sessions.pop(token_hash, None) is not None:
self._persist_locked()
def revoke_all_for(self, user_id: str) -> None:
"""Immediately delete every session of *user_id* (deactivation)."""
with self._lock:
doomed = [h for h, s in self._sessions.items() if s.user_id == user_id]
for token_hash in doomed:
del self._sessions[token_hash]
if doomed:
self._persist_locked()
def generate_token(self) -> str:
return secrets.token_urlsafe(32)
# ----------------------------------------------------------- asyncio shim
async def arevoke(self, token: str) -> None:
await asyncio.to_thread(self.revoke, token)
async def arevoke_all_for(self, user_id: str) -> None:
await asyncio.to_thread(self.revoke_all_for, user_id)
+203
View File
@@ -0,0 +1,203 @@
"""User store — durable JSON-backed account registry (REQ-69, D-102/D-105).
Persisted to ``<data_root>/users.json`` with the atomic tmp+``os.replace``
idiom (D-017). Password hashing is argon2id via argon2-cffi
``PasswordHasher()`` defaults; every hash/verify runs through
``asyncio.to_thread`` because argon2 verify at default parameters costs
~50-100ms of CPU and must leave the event loop alone.
"""
import asyncio
import json
import os
import secrets
import threading
from datetime import UTC, datetime
from pathlib import Path
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from researchx.config.ids import generate_slug
MIN_PASSWORD_LENGTH = 12
class UserError(ValueError):
"""Raised for user-store policy violations (duplicate email, weak password)."""
class User(BaseModel):
user_id: str = Field(pattern=r"^[a-z0-9]{8}$")
email: str
password_hash: str
display_name: str
role: str = "user"
active: bool = True
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
model_config = ConfigDict(frozen=True)
class UserStore:
"""In-memory map keyed by user_id + durable users.json mirror."""
def __init__(self, path: Path) -> None:
self._path = path
self._by_id: dict[str, User] = {}
self._by_email: dict[str, User] = {}
self._lock = threading.Lock()
self._hasher = PasswordHasher()
self._load()
# ------------------------------------------------------------ persistence
def _load(self) -> None:
if not self._path.exists():
return
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
# FAIL CLOSED on a corrupt user store. Swallowing it would
# silently report an empty store, reopening registration on
# top of existing accounts — the next persist would then
# overwrite them (auth-reset-by-corruption). The file is
# renamed aside so an operator can recover it; startup is
# refused rather than silently degrading.
salvage = self._path.with_name(f"{self._path.name}.corrupt.{secrets.token_hex(4)}")
os.replace(self._path, salvage)
raise RuntimeError(
f"corrupt user store {self._path}; moved aside to {salvage}"
) from exc
for record in raw.get("users", []):
try:
user = User.model_validate(record)
except (ValidationError, KeyError, TypeError):
continue # defensive: skip corrupt records on load
self._by_id[user.user_id] = user
self._by_email[user.email] = user
def _persist_locked(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {"users": [u.model_dump(mode="json") for u in self._by_id.values()]}
tmp_path = self._path.with_name(f"{self._path.name}.{secrets.token_hex(4)}.tmp")
content = json.dumps(payload, indent=2)
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, self._path)
def _persist(self) -> None:
with self._lock:
self._persist_locked()
# ------------------------------------------------------------- hashing
async def hash_password(self, password: str) -> str:
return await asyncio.to_thread(self._hasher.hash, password)
async def verify_password(self, user: User, password: str) -> bool:
try:
await asyncio.to_thread(self._hasher.verify, user.password_hash, password)
except VerifyMismatchError:
return False
return True
async def needs_rehash(self, user: User) -> bool:
return await asyncio.to_thread(self._hasher.check_needs_rehash, user.password_hash)
# ------------------------------------------------------------- queries
def empty(self) -> bool:
return not self._by_id
def get(self, user_id: str) -> User | None:
return self._by_id.get(user_id)
def get_by_email(self, email: str) -> User | None:
return self._by_email.get(email.casefold())
def list(self) -> list[User]:
return sorted(self._by_id.values(), key=lambda u: (u.created_at, u.user_id))
# ------------------------------------------------------------- mutation
async def create_user(
self,
*,
email: str,
password: str,
display_name: str,
role: str = "user",
) -> User:
normalized = email.casefold()
if len(password) < MIN_PASSWORD_LENGTH:
raise UserError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
if self._by_email.get(normalized) is not None:
raise UserError("email already registered")
password_hash = await self.hash_password(password)
user = User(
user_id=generate_slug(),
email=normalized,
password_hash=password_hash,
display_name=display_name,
role=role,
)
with self._lock:
if self._by_email.get(normalized) is not None:
raise UserError("email already registered")
self._by_id[user.user_id] = user
self._by_email[user.email] = user
self._persist_locked()
return user
async def rehash_password(self, user: User, password: str) -> None:
"""Rehash a user's password in place (login-time rehash on parameter drift)."""
password_hash = await self.hash_password(password)
with self._lock:
updated = user.model_copy(update={"password_hash": password_hash})
self._by_id[updated.user_id] = updated
self._by_email[updated.email] = updated
self._persist_locked()
async def create_first_admin(self, *, email: str, password: str, display_name: str) -> User:
"""Atomically create the first user, who is admin by construction.
The emptiness check + insert both happen under the store lock;
the only await (the argon2 hash) precedes the check. Combined
with the single uvicorn worker this makes registration open
exactly once (D-104).
"""
if len(password) < MIN_PASSWORD_LENGTH:
raise UserError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
normalized = email.casefold()
password_hash = await self.hash_password(password) # the one allowed await
with self._lock:
if self._by_id:
raise UserError("registration closed")
if self._by_email.get(normalized) is not None:
raise UserError("email already registered")
user = User(
user_id=generate_slug(),
email=normalized,
password_hash=password_hash,
display_name=display_name,
role="admin",
)
self._by_id[user.user_id] = user
self._by_email[user.email] = user
self._persist_locked()
return user
def deactivate(self, user_id: str) -> User | None:
with self._lock:
user = self._by_id.get(user_id)
if user is None:
return None
updated = user.model_copy(update={"active": False})
self._by_id[user_id] = updated
self._by_email[updated.email] = updated
self._persist_locked()
return updated
+25
View File
@@ -0,0 +1,25 @@
"""8-char slug ids — the single generator + validation (P-14 consolidation).
Run ids and user ids share one alphabet and one shape: 8 lowercase
alphanumeric characters, validated by ``^[a-z0-9]{8}$`` (D-105 — the
user_id doubles as a filesystem-safe partition key). Both
``adapters.local_storage`` and ``config.run_config`` import from here so
there is exactly one generator left in the tree.
"""
import re
import secrets
ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"
SLUG_RE = re.compile(r"^[a-z0-9]{8}$")
def generate_slug() -> str:
"""Generate a random 8-char slug from the shared alphabet."""
return "".join(secrets.choice(ALPHABET) for _ in range(8))
def is_valid_slug(value: str) -> bool:
"""Return True when *value* is a well-formed 8-char slug."""
return SLUG_RE.match(value) is not None
+31
View File
@@ -0,0 +1,31 @@
"""Data-root resolution (D-107).
All mutable, host-persisted state (users.json, sessions.json, runs/,
keys/, settings/) lives under one directory:
- ``RESEARCHX_DATA_DIR`` when set (container: ``/var/lib/researchx``),
- else ``backend/var/researchx`` relative to the repository root (dev).
Replaces the cwd-dependent ``Path.cwd() / ".researchx"`` storage default.
"""
import os
from pathlib import Path
_ENV_VAR = "RESEARCHX_DATA_DIR"
# src/researchx/config/paths.py -> repo root is four parents up.
_REPO_ROOT = Path(__file__).resolve().parents[4]
_DEV_DEFAULT = _REPO_ROOT / "backend" / "var" / "researchx"
def data_root() -> Path:
"""Return the mutable data root, creating it on demand.
The directory (and its parent chain) is created lazily so that
importing this module never touches the filesystem.
"""
raw = os.environ.get(_ENV_VAR)
root = Path(raw).expanduser() if raw else _DEV_DEFAULT
root.mkdir(parents=True, exist_ok=True)
return root
+6 -2
View File
@@ -1,4 +1,3 @@
import secrets
from collections.abc import Mapping
from datetime import datetime
from typing import Any
@@ -6,6 +5,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from researchx.config.dials import Dials
from researchx.config.ids import generate_slug
from researchx.methodologies.registry import MethodologyRegistry
@@ -23,6 +23,9 @@ class RunConfig(BaseModel, frozen=True):
resolved_dials: Dials
created_at: datetime
overrides: dict[str, Any] = Field(default_factory=dict)
# Owning user (REQ-71). Optional = serialization-compatible with
# pre-v0.3 config files (D-106: no migration).
owner_id: str | None = None
model_config = ConfigDict(frozen=True)
@@ -43,7 +46,8 @@ def resolve_dials(
def new_run_id() -> str:
return secrets.token_hex(4)
"""Generate a run id (P-14: single shared generator — config/ids.py)."""
return generate_slug()
__all__ = ["RunConfig", "UnknownOverrideError", "new_run_id", "resolve_dials"]
+5
View File
@@ -351,7 +351,11 @@ def make_run_config(
overrides: dict[str, object],
registry: MethodologyRegistry,
run_id: str | None = None,
owner_id: str | None = None,
) -> tuple[str, RunConfig]:
"""Build a run config; ``owner_id`` (when given) travels with the
config and is stamped on the run state by the wiring layer (T-1.7).
"""
from researchx.adapters.local_storage import generate_run_id
rid = run_id or generate_run_id()
@@ -364,5 +368,6 @@ def make_run_config(
resolved_dials=dials,
overrides=dict(overrides),
created_at=datetime.now(UTC),
owner_id=owner_id,
)
return rid, config
+3 -1
View File
@@ -13,12 +13,14 @@ from researchx.methodologies import registry
async def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
owner_id = "demo0001" # 8-char owner slug (P-9: demo passes the owner)
run_id, config = make_run_config(
question="How do verification pipelines keep citations honest?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
owner_id=owner_id,
)
plan = (
'{"sub_questions": ["verification passes", "quote anchoring"], '
@@ -36,7 +38,7 @@ async def main() -> None:
]
)
search = FakeSearchAdapter(CORPUS)
storage = LocalStorageAdapter(base_dir=base)
storage = LocalStorageAdapter(base_dir=base, owner_id=owner_id)
bus = EventBus()
actor = RunActor(
run_config=config, model=model, search=search, storage=storage, registry=registry,
@@ -18,6 +18,9 @@ class RunState(BaseModel):
context: str = ""
methodology_slug: str
state: RunStateName = RunStateName.PLANNING
# 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
plan: Plan | None = None
depth_cursor: int = 0
depth_target: int = 1
+8 -1
View File
@@ -15,7 +15,14 @@ class RunStateName(StrEnum):
_LEGAL_TRANSITIONS: dict[RunStateName, set[RunStateName]] = {
RunStateName.PLANNING: {RunStateName.AWAITING_APPROVAL, RunStateName.FAILED},
# G-101: PLANNING -> CANCELLED is legal — the restart sweep rewrites
# in-flight PLANNING runs as CANCELLED; semantically a run that has
# not produced a plan can always be abandoned.
RunStateName.PLANNING: {
RunStateName.AWAITING_APPROVAL,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.AWAITING_APPROVAL: {
RunStateName.SEARCHING,
RunStateName.CANCELLED,
+22
View File
@@ -0,0 +1,22 @@
"""Shared test fixtures."""
import pytest
@pytest.fixture(autouse=True)
def _reset_sse_starlette_loop_binding():
"""Reset sse_starlette's module-global shutdown Event before each test.
``sse_starlette.AppStatus.should_exit_event`` is an ``anyio.Event``
memoized at the first SSE response and bound to that event loop.
pytest-asyncio gives every test a fresh loop, so a second SSE test in
one process would die with "Event is bound to a different event
loop". Resetting the global before each test keeps the library's
loop-binding per-test. (Production is unaffected: single process,
single loop, single uvicorn worker by design.)
"""
from sse_starlette.sse import AppStatus
AppStatus.should_exit = False
AppStatus.should_exit_event = None
yield
+4
View File
@@ -137,6 +137,8 @@ async def test_attack_pause_resume_mid_depth_loop_over_http(tmp_path: Path) -> N
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(canned=canned),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
@@ -196,6 +198,8 @@ async def test_attack_cancel_mid_loop_persists(tmp_path: Path) -> None:
+ [ChatResult(content="# never reached", model="fake-writer")]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
+4
View File
@@ -15,6 +15,8 @@ _WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
def _app(tmp_path: Path):
# Zero-key dev harness (C3-16): auth explicitly off — the synthetic
# dev admin resolves for every request.
return create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
@@ -24,6 +26,8 @@ def _app(tmp_path: Path):
]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
+590
View File
@@ -0,0 +1,590 @@
"""Auth route suite (T-1.9 backend half, REQ-69/70 — RESEARCH §11 list).
Full adversarial surface of the auth layer, run with
``auth_enabled=True``: first-user-admin, registration closing, uniform
login failures, session/logout semantics, CSRF enforcement, admin
create/deactivate (deactivation kills sessions), and the
no-password-hash-anywhere guarantee.
"""
import asyncio
import json
from pathlib import Path
import httpx
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.types import ChatResult
from researchx.api.app import create_app
from researchx.auth.guard import CSRF_HEADER
_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"}
def _app(tmp_path: Path):
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,
)
async def _client(tmp_path: Path) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=_app(tmp_path)),
base_url="http://testserver",
)
async def _register_first_admin(client: httpx.AsyncClient) -> httpx.Response:
return await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
headers=_HEADERS,
)
async def _login(client: httpx.AsyncClient, email: str, password: str) -> httpx.Response:
return await client.post(
"/api/auth/login", json={"email": email, "password": password}, headers=_HEADERS
)
# ------------------------------------------------------------------ register
async def test_healthz_outside_api_is_public(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
async def test_first_register_creates_admin(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await _register_first_admin(client)
assert response.status_code == 201
body = response.json()
assert body["role"] == "admin"
assert body["email"] == _ADMIN_EMAIL
assert body["active"] is True
assert len(body["user_id"]) == 8
assert "password_hash" not in body
assert "password" not in body
async def test_second_register_is_403(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
response = await client.post(
"/api/auth/register",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
assert response.status_code == 403
assert response.json()["detail"] == "registration closed"
async def test_register_rejects_short_password(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": "short"},
headers=_HEADERS,
)
assert response.status_code == 422 # pydantic contract rejects < 12
async def test_register_requires_csrf_header(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
)
assert response.status_code == 403
assert response.json()["detail"] == "missing request header"
async def test_concurrent_register_yields_exactly_one_admin(tmp_path: Path) -> None:
"""D-104 adversarial pin: a simultaneous register storm on a fresh
store must produce exactly one 201/admin — the emptiness check +
insert are atomic under the store lock (create_first_admin), so a
raced duplicate cannot both win. Regression guard for the
create_user-based route that let N concurrent registers through."""
racers = [(f"racer{i}@example.org", f"racing-password-{i:02d}") for i in range(8)]
async with await _client(tmp_path) as client:
responses = await asyncio.gather(
*(
client.post(
"/api/auth/register",
json={"email": email, "password": password},
headers=_HEADERS,
)
for email, password in racers
)
)
winners = [(r, racer) for r, racer in zip(responses, racers, strict=True) if r.status_code == 201]
losers = [r for r in responses if r.status_code == 403]
assert len(winners) == 1, "exactly one register may win the race"
assert len(losers) == 7
assert winners[0][0].json()["role"] == "admin"
# The winner can log in; every losing racer has no account.
_, (winner_email, winner_password) = winners[0]
login = await client.post(
"/api/auth/login",
json={"email": winner_email, "password": winner_password},
headers=_HEADERS,
)
assert login.status_code == 200
for email, password in racers:
if email == winner_email:
continue
denied = await client.post(
"/api/auth/login",
json={"email": email, "password": password},
headers=_HEADERS,
)
assert denied.status_code == 401, "losing racer must have no account"
# -------------------------------------------------------------------- login
async def test_login_sets_httponly_cookie(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
response = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
assert response.status_code == 200
assert response.json()["role"] == "admin"
set_cookie = response.headers["set-cookie"]
assert "researchx_session=" in set_cookie
assert "httponly" in set_cookie.lower()
assert "samesite=lax" in set_cookie.lower()
assert "path=/" in set_cookie.lower()
assert "domain=" not in set_cookie.lower().replace("samesite=lax", "")
assert "secure" not in set_cookie.lower() # cookie_secure=False in tests
async def test_login_wrong_password_uniform_401(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
for email, password in [
(_ADMIN_EMAIL, "wrong-password-here"),
("ghost@example.org", _ADMIN_PASSWORD),
]:
response = await _login(client, email, password)
assert response.status_code == 401
assert response.json()["detail"] == "invalid email or password"
async def test_login_token_rotation(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
first = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
first_cookie = first.headers["set-cookie"].split(";")[0]
second = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
second_cookie = second.headers["set-cookie"].split(";")[0]
assert first_cookie != second_cookie # fresh token every login
# ----------------------------------------------------------- guard + CSRF
async def test_unauthenticated_401_on_protected_routes(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
for method, path in [
("GET", "/api/methodologies"),
("GET", "/api/config"),
("GET", "/api/runs/abcd2345"),
("GET", "/api/runs/abcd2345/events"),
("GET", "/api/runs/abcd2345/report"),
("GET", "/api/admin/users"),
]:
response = await client.request(method, path)
assert response.status_code == 401, f"{method} {path} leaked without auth"
assert response.json()["detail"] == "authentication required"
async def test_csrf_header_required_on_non_get(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
# Session cookie attached, but no CSRF header on a POST.
response = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
)
assert response.status_code == 403
assert response.json()["detail"] == "missing request header"
async def test_gets_exempt_from_csrf_header(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
assert (await client.get("/api/methodologies")).status_code == 200
assert (await client.get("/api/auth/me")).status_code == 200
# ----------------------------------------------------------- me + logout
async def test_me_returns_current_user(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.get("/api/auth/me")
assert response.status_code == 200
body = response.json()
assert body["email"] == _ADMIN_EMAIL
assert "password_hash" not in body
async def test_logout_revokes_session(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
assert (await client.get("/api/methodologies")).status_code == 200
response = await client.post("/api/auth/logout", headers=_HEADERS)
assert response.status_code == 200
assert (await client.get("/api/methodologies")).status_code == 401
async def test_deactivated_user_401(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_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
assert created.status_code == 201
second_id = created.json()["user_id"]
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,
)
assert (await second.get("/api/methodologies")).status_code == 200
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
deactivated = await admin.post(
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
)
assert deactivated.status_code == 200
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
# The deactivated user's session was revoked server-side.
response = await second.get("/api/methodologies")
assert response.status_code == 401
# A fresh login against the deactivated account also 401s —
# uniformly, without revealing the reason (no enumeration).
login = await second.post(
"/api/auth/login",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
assert login.status_code == 401
assert login.json()["detail"] == "invalid email or password"
# ------------------------------------------------------------------- admin
async def test_admin_list_users_no_hashes(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.get("/api/admin/users")
assert response.status_code == 200
body = response.json()
assert len(body) == 1
raw = json.dumps(body)
assert "password_hash" not in raw
assert _ADMIN_PASSWORD not in raw
assert "argon2" not in raw
async def test_admin_create_user_then_user_login(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
assert created.status_code == 201
body = created.json()
assert body["role"] == "user"
assert body["active"] is True
login = await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
assert login.status_code == 200
assert login.json()["role"] == "user"
async def test_admin_create_rejects_short_password(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": "short"},
headers=_HEADERS,
)
assert response.status_code == 422
async def test_admin_deactivate_revokes_sessions(tmp_path: Path) -> None:
app = _app(tmp_path)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as admin:
await _register_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
second_id = created.json()["user_id"]
# A separate client (separate cookie jar) for the second user.
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as second:
await second.post(
"/api/auth/login",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
assert (await second.get("/api/methodologies")).status_code == 200
# Deactivation from the admin session kills the second session.
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as admin:
await _register_first_admin(admin) # store shared; already admin
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
deactivated = await admin.post(
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
)
assert deactivated.status_code == 200
assert deactivated.json()["active"] is False
response = await second.get("/api/methodologies")
assert response.status_code == 401 # session revoked by deactivation
async def test_non_admin_cannot_reach_admin_routes(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
response = await client.get("/api/admin/users")
assert response.status_code == 403
assert response.json()["detail"] == "admin role required"
async def test_admin_deactivate_unknown_user_404(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.post("/api/admin/users/zzzz9999/deactivate", headers=_HEADERS)
assert response.status_code == 404
# ------------------------------------------------------ authenticated runs
async def test_cross_owner_access_404_on_every_run_endpoint(tmp_path: Path) -> None:
"""Two-user isolation (REQ-71): zero visibility across every run
endpoint — cross-owner access is indistinguishable from a missing
run (404, not 403: no existence leak)."""
app = _app(tmp_path)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _register_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/runs",
json={"question": "admin's private question", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
run_id = created.json()["id"]
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:
login = await _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
assert login.status_code == 200
del login # ids don't matter here; counters do
for method, path in [
("GET", f"/api/runs/{run_id}"),
("POST", f"/api/runs/{run_id}/approve"),
("POST", f"/api/runs/{run_id}/pause"),
("POST", f"/api/runs/{run_id}/resume"),
("POST", f"/api/runs/{run_id}/cancel"),
("GET", f"/api/runs/{run_id}/events"),
("GET", f"/api/runs/{run_id}/report"),
("GET", f"/api/runs/{run_id}/ledger"),
]:
response = await second.request(method, path, headers=_HEADERS)
assert response.status_code == 404, f"{method} {path} leaked across owners"
# The owner still sees their run — the 404s were authorization, not
# the run vanishing.
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await admin.get(f"/api/runs/{run_id}")
assert response.status_code == 200
async def test_owner_id_recorded_in_persisted_state(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
login = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
user_id = login.json()["user_id"]
created = await client.post(
"/api/runs",
json={"question": "q", "methodology": "rapid-answer"},
headers=_HEADERS,
)
run_id = created.json()["id"]
state_path = tmp_path / "runs" / user_id / run_id / "state.json"
for _ in range(200): # the actor persists asynchronously
if state_path.exists():
break
await asyncio.sleep(0.01)
assert state_path.exists(), "storage layout must be runs/<user_id>/<run_id>/"
state_text = state_path.read_text()
state = json.loads(state_text)
assert state["owner_id"] == user_id
async def test_cost_counters_owner_keyed(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_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/runs",
json={"question": "q", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
config = await admin.get("/api/config")
counters = config.json()["cost_counters"]
assert counters["runs_created"] == 1 # the admin's own view, owner-keyed
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 _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
created = await second.post(
"/api/runs",
json={"question": "q2", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
config = await second.get("/api/config")
assert config.json()["cost_counters"] == {"runs_created": 1, "runs_completed": 0}
async def test_authenticated_run_lifecycle(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await client.post(
"/api/runs",
json={"question": "How do engines run?", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
run_id = created.json()["id"]
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "awaiting_approval":
break
await asyncio.sleep(0.01)
approved = await client.post(f"/api/runs/{run_id}/approve", headers=_HEADERS)
assert approved.status_code == 200
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "done":
break
await asyncio.sleep(0.01)
report = await client.get(f"/api/runs/{run_id}/report")
assert report.status_code == 200
assert "[p-1]" in report.json()["markdown"]
async def test_no_password_hash_in_any_response(tmp_path: Path) -> None:
"""Every response body of a full auth + run session, scanned."""
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
responses = [
await client.get("/api/auth/me"),
await client.get("/api/admin/users"),
await client.get("/api/methodologies"),
await client.get("/api/config"),
await client.get("/api/runs/abcd2345"),
]
for response in responses:
raw = response.text
assert "$argon2" not in raw # no hash material, ever
assert '"password_hash"' not in raw # no hash field, ever
assert _ADMIN_PASSWORD not in raw
assert _SECOND_PASSWORD not in raw
+8 -8
View File
@@ -46,7 +46,7 @@ def _actor(tmp_path: Path) -> tuple[RunActor, EventBus, FakeSearchAdapter]:
)
model = FakeModelAdapter(canned=_canned())
search = FakeSearchAdapter(CORPUS)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
bus = EventBus()
actor = RunActor(
run_config=config,
@@ -71,20 +71,20 @@ async def test_full_walk_reaches_done(tmp_path: Path) -> None:
assert state.state == RunStateName.DONE
assert state.plan is not None and len(state.plan.sub_questions) == 2
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "DONE"
report = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "report.md")
report = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "report.md")
assert "[p-1]" in report
ledger_raw = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
ledger_raw = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact(
"abcd2345", "ledger.json"
)
ledger = json.loads(ledger_raw)
assert ledger["checklist"]["citations_checked"] >= 1
assert ledger["checklist"]["liveness_ratio"] >= 0
checklist = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
checklist = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact(
"abcd2345", "checklist.md"
)
assert "Verification Checklist" in checklist
@@ -121,7 +121,7 @@ async def test_cancel_persists_cancelled_state(tmp_path: Path) -> None:
await actor.approve()
with pytest.raises(asyncio.CancelledError):
await task
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "CANCELLED"
@@ -160,14 +160,14 @@ async def test_planner_non_json_fails_honestly(tmp_path: Path) -> None:
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
task = asyncio.create_task(actor.run())
with pytest.raises(Exception, match="planner returned non-JSON plan"):
await task
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "FAILED"
+5 -2
View File
@@ -31,7 +31,7 @@ async def test_registry_to_fake_model_to_snapshot_round_trip(tmp_path) -> None:
)
assert run_config.resolved_dials.breadth == 3
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
await storage.write_run_artifact(run_id, "run_config.json", run_config.model_dump_json())
restored = RunConfig.model_validate_json(
@@ -70,9 +70,12 @@ def test_override_rejects_unknown_dial() -> None:
def test_new_run_id_format() -> None:
# P-14: one generator, one alphabet (config/ids.py) — the old
# token_hex(4) duplicate is gone.
run_id = new_run_id()
assert len(run_id) == 8
assert all(c in "0123456789abcdef" for c in run_id)
alphabet = set("abcdefghjkmnpqrstuvwxyz23456789")
assert all(c in alphabet for c in run_id)
async def test_fake_search_feeds_evidence_shape() -> None:
+70 -10
View File
@@ -4,9 +4,15 @@ from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.storage import StorageAdapter
from researchx.adapters.types import AdapterError
_OWNER = "u1a2b3c4"
def _storage(tmp_path: Path) -> LocalStorageAdapter:
return LocalStorageAdapter(base_dir=tmp_path, owner_id=_OWNER)
async def test_storage_round_trip(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
assert isinstance(storage, StorageAdapter)
target = await storage.write_run_artifact("abcd2345", "state.json", '{"state": "PLANNING"}')
assert target.exists()
@@ -15,24 +21,52 @@ async def test_storage_round_trip(tmp_path: Path) -> None:
async def test_atomic_overwrite(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 2}')
assert await storage.read_run_artifact("abcd2345", "state.json") == '{"v": 2}'
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
leftovers = list((tmp_path / "runs" / _OWNER / "abcd2345").glob("*.tmp"))
assert leftovers == []
async def test_run_dir_layout(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
run_dir = storage.run_dir("abcd2345")
assert run_dir == tmp_path / "runs" / "abcd2345"
# D-106: runs/<owner_id>/<run_id>/ partitioning (supersedes D-010).
assert run_dir == tmp_path / "runs" / _OWNER / "abcd2345"
await storage.write_run_artifact("abcd2345", "report.md", "# Report")
assert run_dir.is_dir()
async def test_owner_id_required_and_validated(tmp_path: Path) -> None:
for bad_owner in ["", "short", "waytoolongid123", "ABCD2345", "a b c d e", "../evil"]:
try:
LocalStorageAdapter(base_dir=tmp_path, owner_id=bad_owner)
except AdapterError:
pass
else:
raise AssertionError(f"expected AdapterError for owner_id: {bad_owner!r}")
# owner_id is required — no default partition key.
try:
LocalStorageAdapter(base_dir=tmp_path) # type: ignore[call-arg]
except TypeError:
pass
else:
raise AssertionError("owner_id must be a required positional parameter")
async def test_read_missing_error_embeds_owner_path(tmp_path: Path) -> None:
storage = _storage(tmp_path)
try:
await storage.read_run_artifact("zzzz9999", "state.json")
except AdapterError as exc:
assert f"runs/{_OWNER}/zzzz9999/state.json" in str(exc)
else:
raise AssertionError("expected AdapterError")
async def test_read_missing_raises(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
try:
await storage.read_run_artifact("zzzz9999", "state.json")
except AdapterError:
@@ -42,7 +76,7 @@ async def test_read_missing_raises(tmp_path: Path) -> None:
async def test_path_traversal_rejected(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
for bad_name in ["../escape.md", "/etc/passwd", "a/b.md", "", "."]:
try:
await storage.write_run_artifact("abcd2345", bad_name, "x")
@@ -60,7 +94,33 @@ async def test_path_traversal_rejected(tmp_path: Path) -> None:
async def test_no_tmp_leftover_after_write(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
assert leftovers == []
leftovers = list((tmp_path / "runs" / _OWNER / "abcd2345").glob("*.tmp"))
assert leftovers == []
async def test_partition_isolation_between_owners(tmp_path: Path) -> None:
alice = LocalStorageAdapter(base_dir=tmp_path, owner_id="aaaa1111")
bob = LocalStorageAdapter(base_dir=tmp_path, owner_id="bbbb2222")
await alice.write_run_artifact("abcd2345", "state.json", '{"owner": "alice"}')
try:
await bob.read_run_artifact("abcd2345", "state.json")
except AdapterError:
pass
else:
raise AssertionError("owner partitions must be isolated on disk")
async def test_list_run_ids_scans_owner_partition(tmp_path: Path) -> None:
storage = _storage(tmp_path)
assert storage.list_run_ids() == []
await storage.write_run_artifact("abcd2345", "state.json", "{}")
await storage.write_run_artifact("efgh6789", "state.json", "{}")
# A file (not a dir) and dotfiles are skipped defensively.
(tmp_path / "runs" / _OWNER / "notadir.md").write_text("x")
(tmp_path / "runs" / _OWNER / ".hidden").mkdir()
assert storage.list_run_ids() == ["abcd2345", "efgh6789"]
other = LocalStorageAdapter(base_dir=tmp_path, owner_id="zzzz9999")
assert other.list_run_ids() == [] # separate partition, empty
+4 -4
View File
@@ -59,7 +59,7 @@ async def test_evidence_reaches_writer_prompt(tmp_path: Path) -> None:
run_config=config,
model=model, # type: ignore[arg-type]
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
@@ -101,7 +101,7 @@ async def test_resume_from_searching_mid_run(tmp_path: Path) -> None:
question="q", context="", methodology_slug="rapid-answer",
overrides={}, registry=registry, run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
mid_state = RunState(
run_id="abcd2345",
question="q",
@@ -148,7 +148,7 @@ async def test_cancel_after_approval_persists_cancelled(tmp_path: Path) -> None:
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
@@ -161,7 +161,7 @@ async def test_cancel_after_approval_persists_cancelled(tmp_path: Path) -> None:
with pytest.raises(asyncio.CancelledError):
await task
saved = json.loads(
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
)
assert saved["state"] == "CANCELLED"
+3 -3
View File
@@ -57,7 +57,7 @@ async def test_probe_attaches_to_run_state(tmp_path: Path, monkeypatch) -> None:
run_config=config,
model=fake_model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
@@ -72,7 +72,7 @@ async def test_probe_attaches_to_run_state(tmp_path: Path, monkeypatch) -> None:
state = await task
assert state.state == RunStateName.DONE
saved = json.loads(
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
)
assert saved["probe"]["endpoint"] == "https://ollama.com/v1"
@@ -116,7 +116,7 @@ async def test_probe_failure_never_crashes_the_run(tmp_path: Path, monkeypatch)
run_config=config,
model=fake_model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=bus,
)
+2 -2
View File
@@ -40,7 +40,7 @@ async def test_state_file_round_trip_enables_restart(tmp_path: Path) -> None:
registry=registry,
run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
state = RunState(
run_id=run_id,
question=config.question,
@@ -68,7 +68,7 @@ async def test_actor_resumes_from_restored_state(tmp_path: Path) -> None:
registry=registry,
run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
mid_state = RunState(
run_id=run_id,
question=config.question,
+247
View File
@@ -0,0 +1,247 @@
"""Restart sweep + registry pruning tests (T-1.8, D-115/G-101, P-6)."""
import asyncio
import json
from pathlib import Path
import httpx
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.types import ChatResult
from researchx.api.app import RunRegistry, create_app
from researchx.api.sweep import sweep_in_flight_runs
from researchx.auth.guard import CSRF_HEADER
from researchx.engine.run_state import RunState
from researchx.engine.states import RunStateName, can_transition
_PLAN = json.dumps(
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
)
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
_OWNER = "abcd2345" # owner slug used for fixture trees
def _state_file(run_dir: Path) -> Path:
run_dir.mkdir(parents=True, exist_ok=True)
return run_dir / "state.json"
def _write_state(run_dir: Path, state: RunStateName) -> Path:
state_path = _state_file(run_dir)
record = RunState(
run_id=run_dir.name,
question="q",
methodology_slug="rapid-answer",
state=state,
owner_id=_OWNER,
)
state_path.write_text(record.model_dump_json(), encoding="utf-8")
return state_path
# ------------------------------------------------------------------- G-101
def test_planning_to_cancelled_is_now_legal() -> None:
"""G-101: the sweep's PLANNING -> CANCELLED rewrite is a legal transition."""
assert can_transition(RunStateName.PLANNING, RunStateName.CANCELLED)
def test_sweep_rewrites_planning_fixture_to_cancelled(tmp_path: Path) -> None:
base = tmp_path
_write_state(base / "runs" / _OWNER / "abcd2345", RunStateName.PLANNING)
swept = sweep_in_flight_runs(base)
assert swept == [(_OWNER, "abcd2345")]
state = RunState.model_validate_json(
(base / "runs" / _OWNER / "abcd2345" / "state.json").read_text(encoding="utf-8")
)
assert state.state == RunStateName.CANCELLED
def test_sweep_rewrites_every_non_terminal_state(tmp_path: Path) -> None:
base = tmp_path
non_terminal = [
RunStateName.PLANNING,
RunStateName.AWAITING_APPROVAL,
RunStateName.SEARCHING,
RunStateName.EXTRACTING,
RunStateName.REFLECTING,
RunStateName.SYNTHESIZING,
RunStateName.VERIFYING,
]
for state in non_terminal:
_write_state(base / "runs" / _OWNER / f"run{state.value[:4].lower()}", state)
swept = sweep_in_flight_runs(base)
assert len(swept) == len(non_terminal)
for _, run_id in swept:
record = RunState.model_validate_json(
(base / "runs" / _OWNER / run_id / "state.json").read_text(encoding="utf-8")
)
assert record.state == RunStateName.CANCELLED
def test_sweep_leaves_terminal_states_untouched(tmp_path: Path) -> None:
base = tmp_path
_write_state(base / "runs" / _OWNER / "done0001", RunStateName.DONE)
_write_state(base / "runs" / _OWNER / "fail0001", RunStateName.FAILED)
_write_state(base / "runs" / _OWNER / "canc0001", RunStateName.CANCELLED)
assert sweep_in_flight_runs(base) == []
for run_id in ["done0001", "fail0001", "canc0001"]:
record = RunState.model_validate_json(
(base / "runs" / _OWNER / run_id / "state.json").read_text(encoding="utf-8")
)
assert record.state in {RunStateName.DONE, RunStateName.FAILED, RunStateName.CANCELLED}
def test_sweep_skips_corrupt_state_and_dotfiles(tmp_path: Path) -> None:
base = tmp_path
good = _write_state(base / "runs" / _OWNER / "abcd2345", RunStateName.SEARCHING)
corrupt = _state_file(base / "runs" / _OWNER / "zzzz9999")
corrupt.write_text("{not json", encoding="utf-8")
(base / "runs" / _OWNER / ".hidden").mkdir() # dotfile dir skipped
(base / "runs" / ".hidden-owner").mkdir() # dotfile owner skipped
swept = sweep_in_flight_runs(base)
assert swept == [(_OWNER, "abcd2345")]
assert corrupt.read_text(encoding="utf-8") == "{not json" # untouched
del good
def test_sweep_on_empty_base_dir_is_noop(tmp_path: Path) -> None:
assert sweep_in_flight_runs(tmp_path) == []
assert sweep_in_flight_runs(tmp_path / "missing") == []
def test_create_app_sweeps_on_construction(tmp_path: Path) -> None:
"""The PLANNING fixture -> create_app -> state is CANCELLED on disk."""
_write_state(tmp_path / "runs" / _OWNER / "abcd2345", RunStateName.PLANNING)
create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[ChatResult(content=_PLAN, model="fake-planner")]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
state = RunState.model_validate_json(
(tmp_path / "runs" / _OWNER / "abcd2345" / "state.json").read_text(encoding="utf-8")
)
assert state.state == RunStateName.CANCELLED
# --------------------------------------------------------------------- P-6
async def test_registry_prune_drops_terminal_run_with_zero_subscribers() -> None:
registry = RunRegistry()
assert registry.prune("abcd2345", "efgh6789") is False # unknown entry: no-op
# Build a fake terminal entry.
from researchx.engine.events import EventBus
bus = EventBus()
class _FakeActor:
pass
class _FakeState:
state = RunStateName.DONE
fake_actor = _FakeActor()
fake_actor.state = _FakeState() # type: ignore[assignment]
async def _noop() -> RunState:
raise NotImplementedError
task: object = asyncio.Future() # placeholder, never awaited
registry.add("abcd2345", "efgh6789", fake_actor, task, bus) # type: ignore[arg-type]
assert registry.has("abcd2345", "efgh6789")
# A live subscriber blocks pruning.
registry.subscribe_events("abcd2345", "efgh6789")
assert registry.prune("abcd2345", "efgh6789") is False
assert registry.has("abcd2345", "efgh6789")
# Last subscriber leaves -> terminal run pruned.
registry.unsubscribe_events("abcd2345", "efgh6789")
assert registry.prune("abcd2345", "efgh6789") is True
assert not registry.has("abcd2345", "efgh6789")
assert registry.prune("abcd2345", "efgh6789") is False # already gone
def test_registry_refcounts_multiple_subscribers() -> None:
registry = RunRegistry()
registry.subscribe_events("abcd2345", "efgh6789")
registry.subscribe_events("abcd2345", "efgh6789")
assert registry.subscriber_count("abcd2345", "efgh6789") == 2
registry.unsubscribe_events("abcd2345", "efgh6789")
assert registry.subscriber_count("abcd2345", "efgh6789") == 1
registry.unsubscribe_events("abcd2345", "efgh6789")
assert registry.subscriber_count("abcd2345", "efgh6789") == 0
registry.unsubscribe_events("abcd2345", "efgh6789") # underflow-safe
assert registry.subscriber_count("abcd2345", "efgh6789") == 0
async def test_events_for_pruned_run_404(tmp_path: Path) -> None:
"""Terminal run + all subscribers gone -> registry drops it; the
events endpoint 404s afterwards (documented acceptable — the
trace persists on disk, D-115/P-6)."""
app = 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=False,
data_dir=tmp_path,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
)
run_id = created.json()["id"]
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "awaiting_approval":
break
await asyncio.sleep(0.01)
await client.post(f"/api/runs/{run_id}/approve", headers={CSRF_HEADER: "1"})
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "done":
break
await asyncio.sleep(0.01)
# Consume the full SSE replay; on stream end the run prunes.
collected = b""
async with client.stream("GET", f"/api/runs/{run_id}/events") as response:
assert response.status_code == 200
async for chunk in response.aiter_bytes():
collected += chunk
if b"run_done" in collected:
break
await asyncio.sleep(0.1) # let the finally-block prune
# The report still exists on disk...
report = await client.get(f"/api/runs/{run_id}/report")
assert report.status_code in (200, 404) # read-model parity lands in P3
state_path = list((tmp_path / "runs").glob(f"*/{run_id}/state.json"))
assert state_path, "trace/state persists on disk after prune"
# ...and a second events request for the pruned run 404s (the
# documented post-prune behavior).
assert (await client.get(f"/api/runs/{run_id}/events")).status_code == 404
+263
View File
@@ -0,0 +1,263 @@
"""Store-level tests for the auth package (T-1.1/T-1.2/T-1.3, REQ-69)."""
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from researchx.auth.sessions import SESSION_TTL_SECONDS, Session, SessionStore
from researchx.auth.users import MIN_PASSWORD_LENGTH, UserError, UserStore
_EMAIL = "owner@example.org"
_PASSWORD = "correct-horse-battery"
def _store(tmp_path: Path) -> UserStore:
return UserStore(tmp_path / "users.json")
def _sessions(tmp_path: Path) -> SessionStore:
return SessionStore(tmp_path / "sessions.json")
# ------------------------------------------------------------------ users
async def test_user_store_create_get_list(tmp_path: Path) -> None:
store = _store(tmp_path)
assert store.empty() is True
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner", role="admin"
)
assert store.empty() is False
assert user.role == "admin"
assert user.active is True
assert len(user.user_id) == 8
assert user.user_id.isascii() and user.user_id.islower()
fetched = store.get(user.user_id)
assert fetched is not None and fetched.email == _EMAIL
by_email = store.get_by_email("Owner@Example.ORG")
assert by_email is not None and by_email.user_id == user.user_id
listed = store.list()
assert [u.user_id for u in listed] == [user.user_id]
assert store.get("zzzz9999") is None
async def test_password_policy_min_12_enforced(tmp_path: Path) -> None:
store = _store(tmp_path)
with pytest.raises(UserError, match="at least 12"):
await store.create_user(
email=_EMAIL, password="short", display_name="Owner"
)
assert len("short") < MIN_PASSWORD_LENGTH
assert store.empty() is True # nothing inserted
with pytest.raises(UserError):
await store.create_user(
email=_EMAIL, password="a" * (MIN_PASSWORD_LENGTH - 1), display_name="Owner"
)
assert store.empty() is True
ok = await store.create_user(
email=_EMAIL, password="a" * MIN_PASSWORD_LENGTH, display_name="Owner"
)
assert ok.user_id
async def test_duplicate_email_rejected(tmp_path: Path) -> None:
store = _store(tmp_path)
await store.create_user(email=_EMAIL, password=_PASSWORD, display_name="A")
with pytest.raises(UserError, match="already registered"):
await store.create_user(
email="OWNER@example.org", password=_PASSWORD, display_name="B"
)
assert len(store.list()) == 1
async def test_argon2_hash_verify_round_trip(tmp_path: Path) -> None:
store = _store(tmp_path)
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner"
)
assert user.password_hash != _PASSWORD
assert user.password_hash.startswith("$argon2")
assert await store.verify_password(user, _PASSWORD) is True
wrong = user.model_copy(update={"password_hash": user.password_hash})
assert await store.verify_password(wrong, "wrong-password-entirely") is False
other = await _store(tmp_path).create_user(
email="b@example.org", password="another-password-12", display_name="B"
)
assert other.password_hash != user.password_hash # unique salts
async def test_user_store_persists_across_reload(tmp_path: Path) -> None:
path = tmp_path / "users.json"
store = UserStore(path)
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner", role="admin"
)
assert path.exists()
reloaded = UserStore(path)
fetched = reloaded.get(user.user_id)
assert fetched is not None
assert fetched.email == _EMAIL
assert await reloaded.verify_password(fetched, _PASSWORD) # hash survives the round-trip
assert reloaded.get_by_email("OWNER@EXAMPLE.ORG") is not None
async def test_user_store_deactivate(tmp_path: Path) -> None:
store = _store(tmp_path)
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner"
)
updated = store.deactivate(user.user_id)
assert updated is not None and updated.active is False
assert store.get(user.user_id).active is False
assert store.deactivate("zzzz9999") is None
# ---------------------------------------------------------------- sessions
async def test_session_create_get_round_trip(tmp_path: Path) -> None:
store = _store(tmp_path)
user = await store.create_user(email=_EMAIL, password=_PASSWORD, display_name="Owner")
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, user.user_id)
assert session.token_hash != token # hash-only storage
assert len(session.token_hash) == 64 # sha256 hex
resolved = sessions.get(token)
assert resolved is not None
assert resolved.user_id == user.user_id
assert sessions.get("not-a-real-token") is None
async def test_session_expiry(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, "abcd2345")
assert session.expires_at > datetime.now(UTC)
# Expired sessions resolve to None and are evicted.
expired = Session(
token_hash=session.token_hash,
user_id="abcd2345",
created_at=datetime.now(UTC) - timedelta(days=15),
last_seen=datetime.now(UTC) - timedelta(days=15),
expires_at=datetime.now(UTC) - timedelta(seconds=1),
)
sessions._sessions[session.token_hash] = expired
assert sessions.get(token) is None
assert session.token_hash not in sessions._sessions
async def test_session_sliding_touch(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, "abcd2345")
original_expiry = session.expires_at
old_seen = datetime.now(UTC) - timedelta(days=10)
stale = session.model_copy(
update={"last_seen": old_seen, "expires_at": old_seen + timedelta(seconds=1)}
)
sessions._sessions[stale.token_hash] = stale
sessions.touch(stale)
renewed = sessions.get(token)
assert renewed is not None
assert renewed.expires_at > original_expiry - timedelta(days=10)
assert renewed.last_seen > old_seen
assert renewed.expires_at - renewed.last_seen == timedelta(seconds=SESSION_TTL_SECONDS)
def test_session_touch_persist_throttle(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, "abcd2345")
path = sessions._path
first_write = path.stat().st_mtime_ns
sessions.touch(session) # within 60s of the create persist → throttled
assert path.stat().st_mtime_ns == first_write
sessions._last_persist -= 61.0 # simulate the throttle window elapsing
session = sessions.get(token)
assert session is not None
sessions.touch(session)
assert path.stat().st_mtime_ns != first_write
async def test_session_revoke_semantics(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token_a = sessions.generate_token()
token_b = sessions.generate_token()
token_c = sessions.generate_token()
sessions.create(token_a, "abcd2345")
sessions.create(token_b, "abcd2345")
sessions.create(token_c, "efgh6789")
sessions.revoke(token_a)
assert sessions.get(token_a) is None
assert sessions.get(token_b) is not None # siblings survive single revoke
sessions.revoke_all_for("abcd2345")
assert sessions.get(token_b) is None
assert sessions.get(token_c) is not None # other users untouched
sessions.revoke("unknown-token") # idempotent
sessions.revoke_all_for("zzzz9999")
async def test_session_store_persists_across_reload(tmp_path: Path) -> None:
path = tmp_path / "sessions.json"
sessions = SessionStore(path)
token = sessions.generate_token()
created = sessions.create(token, "abcd2345")
del created
reloaded = SessionStore(path)
assert reloaded.get(token) is not None
reloaded.revoke(token)
assert SessionStore(path).get(token) is None # revocation persisted too
# ------------------------------------------------------- corruption posture
def test_corrupt_user_store_fails_closed(tmp_path: Path) -> None:
"""A corrupt users.json must NOT silently become an empty store:
swallowing it would reopen registration on top of existing
accounts and the next persist would overwrite them. The store
must refuse to start (file moved aside for operator recovery)."""
path = tmp_path / "users.json"
path.write_text("{not json", encoding="utf-8")
with pytest.raises(RuntimeError, match="corrupt user store"):
UserStore(path)
salvage = list(tmp_path.glob("users.json.corrupt.*"))
assert salvage, "the corrupt file must be moved aside, not deleted"
assert not path.exists()
def test_corrupt_session_store_fails_toward_no_access(tmp_path: Path) -> None:
"""A corrupt sessions.json degrades to 'everyone logged out' —
the fail-safe direction for sessions (no access, not all access).
Startup must still succeed; sessions are re-created on next login."""
path = tmp_path / "sessions.json"
path.write_text("{not json", encoding="utf-8")
sessions = SessionStore(path) # must not raise
token = sessions.generate_token()
sessions.create(token, "abcd2345")
assert SessionStore(path).get(token) is not None # recovers on rewrite
+153 -1
View File
@@ -3,7 +3,8 @@ revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
"python_full_version == '3.14.*'",
"python_full_version < '3.14'",
]
[[package]]
@@ -28,6 +29,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" },
{ url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" },
{ url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" },
{ url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" },
{ url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" },
{ url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" },
{ url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" },
{ url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" },
{ url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" },
{ url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" },
{ url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" },
{ url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" },
{ url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" },
{ url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" },
{ url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" },
{ url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" },
{ url = "https://files.pythonhosted.org/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb", size = 25683, upload-time = "2026-08-20T07:33:05.337Z" },
{ url = "https://files.pythonhosted.org/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6", size = 27310, upload-time = "2026-08-20T07:33:06.344Z" },
{ url = "https://files.pythonhosted.org/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990", size = 26771, upload-time = "2026-08-20T07:33:07.429Z" },
{ url = "https://files.pythonhosted.org/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08", size = 27569, upload-time = "2026-08-20T07:33:08.598Z" },
{ url = "https://files.pythonhosted.org/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca", size = 27279, upload-time = "2026-08-20T07:33:09.518Z" },
{ url = "https://files.pythonhosted.org/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1", size = 27774, upload-time = "2026-08-20T07:33:10.728Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36", size = 26933, upload-time = "2026-08-20T07:33:11.661Z" },
{ url = "https://files.pythonhosted.org/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210", size = 24875, upload-time = "2026-08-20T07:33:12.616Z" },
{ url = "https://files.pythonhosted.org/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4", size = 26655, upload-time = "2026-08-20T07:33:13.521Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440", size = 25376, upload-time = "2026-08-20T07:33:14.491Z" },
]
[[package]]
name = "ast-serialize"
version = "0.11.2"
@@ -101,6 +157,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@@ -379,6 +520,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
@@ -512,6 +662,7 @@ name = "researchx"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "argon2-cffi" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
@@ -530,6 +681,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "argon2-cffi", specifier = ">=23.1,<26" },
{ name = "fastapi", specifier = ">=0.115,<0.116" },
{ name = "httpx", specifier = ">=0.27,<1" },
{ name = "pydantic", specifier = ">=2.8,<3" },
+30
View File
@@ -66,6 +66,36 @@ hosts is wayfinding and accumulated research:
| `pnpm typecheck` | `tsc --noEmit` (TypeScript strict) |
| `pnpm test` | Vitest run (jsdom + Testing Library) |
| `pnpm test:watch` | Vitest in watch mode |
| `pnpm generate:api` | Regenerate `src/lib/api/generated/schema.d.ts` from the committed `openapi.json` snapshot (G-005 — never from a live server) |
| `pnpm check:api-drift` | Schema-drift gate: regenerates from the snapshot and diffs (exit 1 on drift — G-005) |
## Auth + sessions (v0.3 P1)
The ApiClient boundary gained the session surface (C3-18 — the
exhaustive list): `login` / `logout` / `getSession` plus the admin
hooks `listUsers` / `createUser` / `deactivateUser`.
- **Mock mode** (the default): a standalone dev fixture — login accepts
any non-empty credentials and swaps an in-memory session (the dev
admin `dev00000`), admin hooks return small fixture arrays, and the
app is never gated behind a login (REQ-31/REQ-54).
- **Live mode** (`NEXT_PUBLIC_RESEARCHX_API_MODE=live`): real routes —
`POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`
(401 → null), `GET /api/admin/users`, `POST /api/admin/users`,
`POST /api/admin/users/{user_id}/deactivate`. Every mutating request
carries the `X-ResearchX-Request` CSRF header (C3-05) and every
request sends `credentials: "include"` so the httpOnly session
cookie rides along. A failed login rejects with a typed `AuthError`
carrying the backend's uniform 401 copy.
The live-mode route guard (`SessionGate` in the root layout) bootstraps
the session on load: an unauthenticated visitor is redirected to
`/login` before any protected content renders; the login screen itself
is never gated. Mock mode skips the check entirely — same components,
zero mock-mode behavior change. Dev-live origins must share the
`localhost` hostname on both sides (`http://localhost:8000` backend +
`http://localhost:3000` frontend — the port is not part of the
same-site computation).
## Architecture notes
+380 -1
View File
@@ -5,6 +5,262 @@
"version": "0.2.0"
},
"paths": {
"/healthz": {
"get": {
"summary": "Healthz",
"operationId": "healthz_healthz_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"title": "Response Healthz Healthz Get"
}
}
}
}
}
}
},
"/api/auth/register": {
"post": {
"tags": [
"auth"
],
"summary": "Register",
"operationId": "register_api_auth_register_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterBody"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPublic"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/auth/login": {
"post": {
"tags": [
"auth"
],
"summary": "Login",
"operationId": "login_api_auth_login_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPublic"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/auth/logout": {
"post": {
"tags": [
"auth"
],
"summary": "Logout",
"operationId": "logout_api_auth_logout_post",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "boolean"
},
"type": "object",
"title": "Response Logout Api Auth Logout Post"
}
}
}
}
}
}
},
"/api/auth/me": {
"get": {
"tags": [
"auth"
],
"summary": "Me",
"operationId": "me_api_auth_me_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPublic"
}
}
}
}
}
}
},
"/api/admin/users": {
"get": {
"tags": [
"admin"
],
"summary": "List Users",
"operationId": "list_users_api_admin_users_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/UserPublic"
},
"type": "array",
"title": "Response List Users Api Admin Users Get"
}
}
}
}
}
},
"post": {
"tags": [
"admin"
],
"summary": "Create User",
"operationId": "create_user_api_admin_users_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminCreateUserBody"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPublic"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/admin/users/{user_id}/deactivate": {
"post": {
"tags": [
"admin"
],
"summary": "Deactivate User",
"operationId": "deactivate_user_api_admin_users__user_id__deactivate_post",
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "User Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPublic"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/config": {
"get": {
"summary": "Config Status",
@@ -417,6 +673,39 @@
},
"components": {
"schemas": {
"AdminCreateUserBody": {
"properties": {
"email": {
"type": "string",
"maxLength": 254,
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
"title": "Email"
},
"password": {
"type": "string",
"minLength": 12,
"title": "Password"
},
"display_name": {
"type": "string",
"maxLength": 120,
"title": "Display Name",
"default": ""
},
"role": {
"type": "string",
"pattern": "^(admin|user)$",
"title": "Role",
"default": "user"
}
},
"type": "object",
"required": [
"email",
"password"
],
"title": "AdminCreateUserBody"
},
"ApproveBody": {
"properties": {
"plan": {
@@ -482,11 +771,13 @@
"properties": {
"question": {
"type": "string",
"maxLength": 2000,
"minLength": 1,
"title": "Question"
},
"context": {
"type": "string",
"maxLength": 8000,
"title": "Context",
"default": ""
},
@@ -520,6 +811,27 @@
"type": "object",
"title": "HTTPValidationError"
},
"LoginBody": {
"properties": {
"email": {
"type": "string",
"maxLength": 254,
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
"title": "Email"
},
"password": {
"type": "string",
"minLength": 1,
"title": "Password"
}
},
"type": "object",
"required": [
"email",
"password"
],
"title": "LoginBody"
},
"MethodologyCard": {
"properties": {
"slug": {
@@ -569,6 +881,33 @@
],
"title": "MethodologyCard"
},
"RegisterBody": {
"properties": {
"email": {
"type": "string",
"maxLength": 254,
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
"title": "Email"
},
"password": {
"type": "string",
"minLength": 12,
"title": "Password"
},
"display_name": {
"type": "string",
"maxLength": 120,
"title": "Display Name",
"default": ""
}
},
"type": "object",
"required": [
"email",
"password"
],
"title": "RegisterBody"
},
"RunSummary": {
"properties": {
"id": {
@@ -614,6 +953,46 @@
],
"title": "RunSummary"
},
"UserPublic": {
"properties": {
"user_id": {
"type": "string",
"title": "User Id"
},
"email": {
"type": "string",
"title": "Email"
},
"display_name": {
"type": "string",
"title": "Display Name"
},
"role": {
"type": "string",
"title": "Role"
},
"active": {
"type": "boolean",
"title": "Active"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"user_id",
"email",
"display_name",
"role",
"active",
"created_at"
],
"title": "UserPublic",
"description": "A user as exposed on the wire \u2014 no password_hash, ever."
},
"ValidationError": {
"properties": {
"loc": {
@@ -649,4 +1028,4 @@
}
}
}
}
}
+3 -1
View File
@@ -10,7 +10,9 @@
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"generate:api": "openapi-typescript ./openapi.json -o src/lib/api/generated/schema.d.ts",
"check:api-drift": "openapi-typescript ./openapi.json -o src/lib/api/generated/schema.d.ts --check"
},
"dependencies": {
"next": "15.5.6",
+13 -6
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "./globals.css";
import { AppSidebar } from "@/components/app-sidebar";
import { AppTopBar } from "@/components/app-top-bar";
import { SessionGate } from "@/components/session-gate";
/**
* Root layout (T-5.2, REQ-63) — the persistent app shell.
@@ -10,6 +11,10 @@ import { AppTopBar } from "@/components/app-top-bar";
* slots) and top bar (⌘K palette + avatar). The shell adds zero
* required clicks to the happy path — it is pure wayfinding chrome.
* Page content lands in one <main> landmark per route (a11y).
*
* v0.3 P1 (T-1.10): the SessionGate wraps the shell in live mode —
* an unauthenticated visitor is redirected to /login before any
* protected content renders. Mock mode passes straight through.
*/
export const metadata: Metadata = {
@@ -26,13 +31,15 @@ export default function RootLayout({
return (
<html lang="en">
<body>
<div className="flex min-h-dvh">
<AppSidebar />
<div className="flex min-w-0 flex-1 flex-col">
<AppTopBar />
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
<SessionGate>
<div className="flex min-h-dvh">
<AppSidebar />
<div className="flex min-w-0 flex-1 flex-col">
<AppTopBar />
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
</div>
</div>
</div>
</SessionGate>
</body>
</html>
);
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { LoginView } from "@/components/login-view";
/**
* /login (T-1.10, REQ-69) — the session entry point in live mode.
* Live-mode users land here via the app-shell guard when no session
* exists; mock mode never routes here (no auth — REQ-31).
*/
export const metadata: Metadata = {
title: "Sign in — ResearchX",
};
export default function LoginPage() {
return <LoginView />;
}
@@ -180,4 +180,19 @@ describe("UserMenu (REQ-66)", () => {
expect(await screen.findByText("Researcher")).toBeInTheDocument();
expect(screen.getByText("Local workspace — single user")).toBeInTheDocument();
});
it("logout calls the boundary then routes to /login (T-1.10, REQ-69)", async () => {
const user = userEvent.setup();
const onDismiss = vi.fn();
render(
<UserMenu onNavigate={() => {}} onDismiss={onDismiss} />,
);
const logout = await screen.findByRole("menuitem", { name: /Log out/i });
expect(logout).toBeInTheDocument();
await user.click(logout);
await vi.waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
expect(onDismiss).toHaveBeenCalled();
});
});
});
+2 -2
View File
@@ -14,8 +14,8 @@ import { usePathname, useRouter } from "next/navigation";
import { forwardRef, useEffect, useRef, useState } from "react";
import {
api,
type DisplayProfile,
type ReportLibrary,
type UserProfile,
} from "@/lib/api/client";
import { CommandPalette, type PaletteEntry } from "@/components/command-palette";
import { UserMenu } from "@/components/user-menu";
@@ -121,7 +121,7 @@ const AvatarButton = forwardRef<HTMLButtonElement, {
menuOpen: boolean;
onToggle: () => void;
}>(function AvatarButton({ menuOpen, onToggle }, ref) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [profile, setProfile] = useState<DisplayProfile | null>(null);
useEffect(() => {
let cancelled = false;
api
+130
View File
@@ -0,0 +1,130 @@
/**
* LoginView tests (T-1.10, REQ-69).
*
* - Renders the email + password form with a disabled submit until both
* fields are filled
* - Submit routes through the boundary: success → /ask; failure → the
* uniform "invalid email or password" error state (401, no
* user enumeration)
*
* The house pattern mocks NO boundary — instead this suite injects a
* boundary-shaped stub through module interception because login is
* the one call whose FAILURE path must be driven deterministically
* (the mock binding never fails a login). The stub speaks only the
* ApiClient surface.
*/
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LoginView } from "@/components/login-view";
import { AuthError, type ApiClient } from "@/lib/api/client";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
usePathname: () => "/login",
}));
/** A boundary-shaped stub driving success/failure of login (REQ-69). */
const loginMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/client", async (importOriginal) => {
const actual = await importOriginal<
Promise<typeof import("@/lib/api/client")>
>();
return {
...actual,
api: {
...(actual.api as unknown as ApiClient),
login: (...args: [string, string]) => loginMock(...args),
} as unknown as ApiClient,
};
});
const SESSION_USER = {
user_id: "abcd2345",
email: "owner@example.org",
display_name: "Owner",
role: "admin",
active: true,
created_at: "2026-09-29T12:00:00Z",
};
describe("LoginView (REQ-69)", () => {
beforeEach(() => {
pushMock.mockClear();
loginMock.mockClear();
});
afterEach(cleanup);
it("renders email + password fields and a disabled submit until both are set", async () => {
const user = userEvent.setup();
render(<LoginView />);
const email = screen.getByLabelText("Email");
const password = screen.getByLabelText("Password");
const submit = screen.getByRole("button", { name: "Sign in" });
expect(submit).toBeDisabled();
await user.type(email, "owner@example.org");
expect(submit).toBeDisabled(); // password still empty
await user.type(password, "not-a-real-secret");
expect(submit).toBeEnabled();
});
it("submits through the boundary and redirects to /ask on success", async () => {
loginMock.mockResolvedValueOnce(SESSION_USER);
const user = userEvent.setup();
render(<LoginView />);
await user.type(screen.getByLabelText("Email"), "owner@example.org");
await user.type(
screen.getByLabelText("Password"),
"correct-horse-battery",
);
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(loginMock).toHaveBeenCalledWith(
"owner@example.org",
"correct-horse-battery",
);
await vi.waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/ask");
});
});
it("shows the uniform 'invalid email or password' error on a rejected login", async () => {
// The backend answers every failure mode uniformly (REQ-69).
loginMock.mockRejectedValueOnce(new AuthError("invalid email or password", 401));
const user = userEvent.setup();
render(<LoginView />);
await user.type(screen.getByLabelText("Email"), "owner@example.org");
await user.type(screen.getByLabelText("Password"), "wrong-password");
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"invalid email or password",
);
expect(pushMock).not.toHaveBeenCalled();
// The submit re-enables for a retry.
expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled();
});
it("surfaces an honest unavailable state on a non-auth failure", async () => {
loginMock.mockRejectedValueOnce(new Error("network down"));
const user = userEvent.setup();
render(<LoginView />);
await user.type(screen.getByLabelText("Email"), "owner@example.org");
await user.type(screen.getByLabelText("Password"), "whatever");
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"sign-in unavailable",
);
expect(pushMock).not.toHaveBeenCalled();
});
});
+109
View File
@@ -0,0 +1,109 @@
"use client";
/**
* LoginView (T-1.10, REQ-69) — the /login screen client.
*
* Email + password against POST /api/auth/login through the ApiClient
* boundary. On success the app lands on /ask (the front door); on 401
* the error is the backend's uniform "invalid email or password" — no
* user enumeration, client-side included. A first-run hint points at
* the backend's register-once rule (the first user is admin).
*/
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { api, AuthError } from "@/lib/api/client";
export function LoginView() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = email.trim() !== "" && password !== "" && !submitting;
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
api
.login(email.trim(), password)
.then(() => {
router.push("/ask");
})
.catch((err: unknown) => {
// Uniform 401 copy — the backend never says WHICH half failed.
if (err instanceof AuthError) {
setError("invalid email or password");
} else {
setError("sign-in unavailable — try again");
}
setSubmitting(false);
});
}
return (
<div className="mx-auto flex max-w-md flex-col px-6 py-section">
<header className="mb-block">
<h1 className="text-display font-semibold text-ink">Sign in.</h1>
<p className="measure-reading mt-4 text-lead text-ink-soft">
ResearchX runs under your account — your research library,
reports, and settings are yours alone.
</p>
</header>
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email" className="block text-body font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
placeholder="you@example.org"
className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
/>
<label htmlFor="password" className="mt-6 block text-body font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••••••"
className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
/>
{error !== null && (
<p role="alert" className="mt-4 text-small text-danger">
{error}
</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="mt-8 rounded-pill bg-accent px-7 py-3 text-body font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? "Signing in…" : "Sign in"}
</button>
</form>
<p className="mt-block text-caption text-ink-faint">
First run? The first account created becomes the administrator.
</p>
</div>
);
}
@@ -0,0 +1,101 @@
/**
* SessionGate tests (T-1.10, REQ-69) — the live-mode route guard.
*
* The gate consults the boundary's getSession() on mount:
* - live mode + no session → redirect to /login
* - live mode + session → content renders
* - live mode + /login route → never gated (the login screen itself)
* - mock mode → no getSession call at all, content
* renders immediately (REQ-31/REQ-54)
*
* Mode is a module constant resolved from env at import time, so this
* suite exercises the gate's decision logic with the boundary stubbed
* per case; the mock-mode fast path is pinned against the REAL
* binding (the default mock singleton).
*/
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionGate } from "@/components/session-gate";
import type { ApiClient } from "@/lib/api/client";
const replaceMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: replaceMock }),
usePathname: () => "/ask",
}));
const getSessionMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/client", async (importOriginal) => {
const actual = await importOriginal<
Promise<typeof import("@/lib/api/client")>
>();
return {
...actual,
liveMode: true, // the gate only acts in live mode
api: {
...(actual.api as unknown as ApiClient),
getSession: () => getSessionMock(),
} as unknown as ApiClient,
};
});
const SESSION_USER = {
user_id: "abcd2345",
email: "owner@example.org",
display_name: "Owner",
role: "admin",
active: true,
created_at: "2026-09-29T12:00:00Z",
};
describe("SessionGate (T-1.10, REQ-69)", () => {
beforeEach(() => {
replaceMock.mockClear();
getSessionMock.mockReset();
});
afterEach(cleanup);
it("redirects an unauthenticated visitor to /login before content renders", async () => {
// Persistent mock: React StrictMode double-invokes effects in tests,
// so the boundary stub must answer every call the same way.
getSessionMock.mockResolvedValue(null);
render(
<SessionGate>
<p>Protected screen</p>
</SessionGate>,
);
// Guard content never flashes for an unauthenticated visitor.
expect(screen.queryByText("Protected screen")).toBeNull();
await vi.waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/login");
});
expect(getSessionMock).toHaveBeenCalled();
});
it("renders the shell once a session resolves", async () => {
getSessionMock.mockResolvedValue(SESSION_USER);
render(
<SessionGate>
<p>Protected screen</p>
</SessionGate>,
);
expect(await screen.findByText("Protected screen")).toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalled();
});
it("fails closed when the session probe errors", async () => {
getSessionMock.mockRejectedValue(new Error("backend unreachable"));
render(
<SessionGate>
<p>Protected screen</p>
</SessionGate>,
);
await vi.waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/login");
});
expect(screen.queryByText("Protected screen")).toBeNull();
});
});
+59
View File
@@ -0,0 +1,59 @@
"use client";
/**
* SessionGate (T-1.10, REQ-69) — the live-mode route guard.
*
* A session bootstrap in the app shell: on mount it asks the boundary
* for the current session; in live mode an unauthenticated user is
* redirected to /login. Mock mode skips the check entirely (the mock
* is a standalone dev/test fixture with no auth — REQ-31/REQ-54), so
* its behavior stays byte-for-byte unchanged.
*
* While the session resolves the screen is blank rather than flashing
* protected content at an unauthenticated visitor (no data ever
* renders before the guard clears).
*/
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { api, liveMode } from "@/lib/api/client";
export function SessionGate({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname() ?? "";
// The login screen itself is never gated (nor redirected).
const isLoginRoute = pathname === "/login";
// Mock mode never gates: render immediately, no getSession() call.
const [ready, setReady] = useState(!liveMode || isLoginRoute);
useEffect(() => {
if (!liveMode || isLoginRoute) return;
let cancelled = false;
api
.getSession()
.then((session) => {
if (cancelled) return;
if (session === null) {
router.replace("/login");
} else {
setReady(true);
}
})
.catch(() => {
// A guard that cannot ask the backend fails closed.
if (!cancelled) router.replace("/login");
});
return () => {
cancelled = true;
};
}, [router, isLoginRoute]);
if (!ready) {
return (
<div className="flex min-h-dvh items-center justify-center">
<p className="text-caption text-ink-faint">Loading…</p>
</div>
);
}
return <>{children}</>;
}
@@ -0,0 +1,146 @@
/**
* UserMenu session display tests (T-1.10, REQ-69) — the avatar menu
* shows the REAL profile in live mode (display name + email from
* getSession) and keeps the fixture in mock mode, plus Logout wiring.
*
* The menu consults the boundary twice: getUserProfile() for the
* fixture display and getSession() for the live account. This suite
* stubs both through the ApiClient surface, driving live mode with a
* resolved session and mock mode with the real mock binding.
*/
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { UserMenu } from "@/components/user-menu";
import type { ApiClient, UserProfile } from "@/lib/api/client";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
usePathname: () => "/ask",
}));
// Boundary + mode injection: the suite swaps the module's `api` and
// `liveMode` exports per case (the menu reads both). The mock's `api`
// delegates to state.api at CALL time so re-assignment per test works.
const state = vi.hoisted(() => ({
api: {} as Record<string, unknown>,
liveMode: false,
}));
vi.mock("@/lib/api/client", async (importOriginal) => {
const actual = await importOriginal<
Promise<typeof import("@/lib/api/client")>
>();
return {
...actual,
api: new Proxy(
{},
{
get: (_target, prop) => {
const handler = state.api[prop as string];
return typeof handler === "function"
? (handler as (...args: unknown[]) => unknown).bind(state.api)
: handler;
},
},
),
// Getter export: the menu reads liveMode at render time, so the
// per-test assignment below must be visible.
get liveMode() {
return state.liveMode;
},
};
});
const SESSION_USER: UserProfile = {
user_id: "abcd2345",
email: "owner@example.org",
display_name: "Ada Owner",
role: "admin",
active: true,
created_at: "2026-09-29T12:00:00Z",
};
/** Boundary-shaped stub with an injectable live-mode flag + session. */
function makeApi(overrides: {
session: UserProfile | null;
logout: () => Promise<void>;
}): ApiClient {
return {
login: vi.fn(),
logout: vi.fn(overrides.logout),
getSession: vi.fn().mockResolvedValue(overrides.session),
listUsers: vi.fn(),
createUser: vi.fn(),
deactivateUser: vi.fn(),
getUserProfile: vi.fn().mockResolvedValue({
name: "Researcher",
initials: "RS",
tagline: "Local workspace — single user",
}),
} as unknown as ApiClient;
}
describe("UserMenu session display (T-1.10, REQ-69)", () => {
beforeEach(() => {
pushMock.mockClear();
});
afterEach(cleanup);
it("live mode shows the real session profile — display name + email", async () => {
state.api = makeApi({ session: SESSION_USER, logout: vi.fn() }) as unknown as Record<string, unknown>;
state.liveMode = true;
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
expect(await screen.findByText("Ada Owner")).toBeInTheDocument();
expect(screen.getByText("owner@example.org")).toBeInTheDocument();
// The avatar disc carries the real initials.
expect(screen.getByText("AO")).toBeInTheDocument();
// Never the fixture name in live mode with a resolved session.
expect(screen.queryByText("Researcher")).toBeNull();
});
it("mock mode keeps the fixture profile unchanged", async () => {
state.api = makeApi({ session: SESSION_USER, logout: vi.fn() }) as unknown as Record<string, unknown>;
state.liveMode = false;
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
expect(await screen.findByText("Researcher")).toBeInTheDocument();
expect(
screen.getByText("Local workspace — single user"),
).toBeInTheDocument();
});
it("logout calls the boundary then routes to /login", async () => {
const logout = vi.fn().mockResolvedValue(undefined);
const onDismiss = vi.fn();
state.api = makeApi({ session: SESSION_USER, logout }) as unknown as Record<string, unknown>;
state.liveMode = true;
const user = userEvent.setup();
render(<UserMenu onNavigate={() => {}} onDismiss={onDismiss} />);
await user.click(
await screen.findByRole("menuitem", { name: /Log out/i }),
);
await vi.waitFor(() => {
expect(logout).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith("/login");
expect(onDismiss).toHaveBeenCalled();
});
});
it("a failed logout still routes away (the cookie dies server-side)", async () => {
const logout = vi.fn().mockRejectedValue(new Error("network down"));
state.api = makeApi({ session: SESSION_USER, logout }) as unknown as Record<string, unknown>;
state.liveMode = true;
const user = userEvent.setup();
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
await user.click(
await screen.findByRole("menuitem", { name: /Log out/i }),
);
await vi.waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
});
+79 -14
View File
@@ -1,16 +1,19 @@
"use client";
/**
* UserMenu (T-5.2, REQ-66) — the avatar button's dropdown.
* UserMenu (T-5.2, REQ-66; T-1.10, REQ-69) — the avatar button's dropdown.
*
* Local single-user profile (no auth — single-user constraint), links to
* Settings and About (the settings page's about section). Escape and
* outside clicks dismiss. Rendered as a menu for assistive tech.
* Shows the profile: the real session user (display name + email via
* getSession) in live mode, the local fixture in mock mode — and a
* Logout action (revokes the session through the boundary, routes to
* /login). Escape and outside clicks dismiss. Rendered as a menu for
* assistive tech.
*/
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, type RefObject } from "react";
import { api, type UserProfile } from "@/lib/api/client";
import { api, liveMode, type DisplayProfile, type UserProfile } from "@/lib/api/client";
export function UserMenu({
onNavigate,
@@ -22,7 +25,10 @@ export function UserMenu({
/** The avatar button — clicks on it toggle, not dismiss-then-reopen. */
toggleRef?: RefObject<HTMLButtonElement | null>;
}) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const router = useRouter();
const [profile, setProfile] = useState<DisplayProfile | null>(null);
const [session, setSession] = useState<UserProfile | null>(null);
const [loggingOut, setLoggingOut] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -35,11 +41,52 @@ export function UserMenu({
.catch(() => {
/* fallback initials render below */
});
// The real session user drives the live-mode display (T-1.10);
// mock mode ignores it and keeps the fixture.
api
.getSession()
.then((user) => {
if (!cancelled) setSession(user);
})
.catch(() => {
/* menu falls back to the fixture display */
});
return () => {
cancelled = true;
};
}, []);
// Live mode with a resolved session shows the real account; mock
// mode (and any unresolved session) shows the fixture unchanged.
const showSession = liveMode && session !== null;
const name = showSession
? (session as UserProfile).display_name
: (profile?.name ?? "Researcher");
const detail = showSession
? (session as UserProfile).email
: (profile?.tagline ?? "Local workspace");
const initials = showSession
? initialsOf((session as UserProfile).display_name)
: (profile?.initials ?? "··");
// Logout: revoke the session through the boundary, then land on
// /login (live mode). Mock mode keeps the identical control — its
// logout is a local no-op that still routes, so the surface behaves
// the same in both bindings.
const handleLogout = () => {
if (loggingOut) return;
setLoggingOut(true);
api
.logout()
.catch(() => {
/* a failed revoke still leaves the app — the cookie dies server-side soonest */
})
.finally(() => {
onDismiss();
router.push("/login");
});
};
// Escape closes; outside clicks close. Focus stays in the header.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
@@ -72,15 +119,11 @@ export function UserMenu({
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-pill bg-accent text-small font-semibold text-paper-raised"
>
{profile?.initials ?? "··"}
{initials}
</span>
<div>
<p className="text-small font-medium text-ink">
{profile?.name ?? "Researcher"}
</p>
<p className="text-caption text-ink-faint">
{profile?.tagline ?? "Local workspace"}
</p>
<p className="text-small font-medium text-ink">{name}</p>
<p className="text-caption text-ink-faint">{detail}</p>
</div>
</div>
@@ -105,11 +148,33 @@ export function UserMenu({
About &amp; version
</Link>
</li>
<li>
<button
type="button"
role="menuitem"
onClick={handleLogout}
disabled={loggingOut}
className="mt-1 w-full rounded-card px-2 py-2 text-left text-small text-ink transition-colors hover:bg-paper-sunken disabled:cursor-not-allowed disabled:opacity-40"
>
{loggingOut ? "Signing out…" : "Log out"}
</button>
</li>
</ul>
<p className="mt-3 border-t border-hairline pt-3 text-caption text-ink-faint">
Local profile — accounts and sync land post-MVP.
{showSession
? `Signed in as ${session?.role} — sessions slide for 14 days.`
: "Local profile — accounts and sync land post-MVP."}
</p>
</div>
);
}
/** Two-letter initials from a display name ("Dev User" → "DU"). */
function initialsOf(displayName: string): string {
const words = displayName.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "··";
const first = words[0]?.[0] ?? "";
const second = words[1]?.[0] ?? words[0]?.[1] ?? "";
return `${first}${second}`.toUpperCase();
}
+69 -2
View File
@@ -10,7 +10,9 @@
import type {
CitationLedger,
ConnectionSlot,
CreateUserInput,
DialValues,
DisplayProfile,
InboxItem,
Methodology,
ProjectGraph,
@@ -33,7 +35,9 @@ export type {
Citation,
CitationLedger,
ConnectionSlot,
CreateUserInput,
DialValues,
DisplayProfile,
GraphEdge,
GraphNode,
InboxItem,
@@ -153,8 +157,11 @@ export interface ApiClient {
*/
getReportConnections(): Promise<ReportConnection[]>;
/** The local single-user profile behind the avatar (REQ-66). */
getUserProfile(): Promise<UserProfile>;
/**
* The menu/display profile behind the avatar (REQ-66). In live mode
* this derives from the session user; in mock mode it is the fixture.
*/
getUserProfile(): Promise<DisplayProfile>;
/** Adapter-slot connection status for the shell + settings (REQ-65). */
getConnectionStatus(): Promise<ConnectionSlot[]>;
@@ -191,8 +198,68 @@ export interface ApiClient {
draft: string,
answers: Record<string, string>,
): Promise<RefinedQuestion>;
/* ---------------------------------------------------------------- */
/* v0.3 P1 session surface (C3-18 exhaustive list — REQ-69, REQ-70). */
/* ---------------------------------------------------------------- */
/**
* Authenticate with email + password (POST /api/auth/login — sets
* the httpOnly session cookie server-side). Rejects with a typed
* AuthError on 401 (uniform "invalid email or password").
*/
login(email: string, password: string): Promise<UserProfile>;
/** Revoke the current session (POST /api/auth/logout). */
logout(): Promise<void>;
/**
* The current session's user, or null when unauthenticated
* (GET /api/auth/me — 401 maps to null, never a throw).
*/
getSession(): Promise<UserProfile | null>;
/** Admin hook: list all users (GET /api/admin/users — REQ-70). */
listUsers(): Promise<UserProfile[]>;
/** Admin hook: create a user with an admin-set password (POST /api/admin/users). */
createUser(input: CreateUserInput): Promise<UserProfile>;
/**
* Admin hook: deactivate a user — kills their sessions, retains
* their data (POST /api/admin/users/{user_id}/deactivate).
*/
deactivateUser(user_id: string): Promise<UserProfile>;
}
/**
* Typed auth failure (T-1.10, REQ-69) — thrown by `login` on 401.
* The backend answers every failure mode uniformly ("invalid email or
* password" — no user enumeration); the client carries the same
* honesty through to the login screen.
*/
export class AuthError extends Error {
/** HTTP status of the failed auth request (401 for bad credentials). */
readonly status: number;
constructor(message = "invalid email or password", status = 401) {
super(message);
this.name = "AuthError";
this.status = status;
}
}
/**
* Whether the app is bound to the live backend (auth applies) or to
* the mock fixture (dev/test — no login, no redirect; REQ-31/REQ-54).
* The login route guard and session bootstrap consult this flag so
* mock mode's zero-auth behavior stays byte-for-byte unchanged.
*/
export const liveMode: boolean =
(process.env.NEXT_PUBLIC_RESEARCHX_API_MODE ??
process.env.RESEARCHX_API_MODE ??
"mock") === "live";
/**
* The app-wide singleton. v0.1: MockApiClient (fixture data + scripted
* event replay, zero API keys). v0.2 P3 (REQ-26/REQ-54): the binding
+7 -2
View File
@@ -161,11 +161,16 @@ describe("E2E happy path — real stack, defaults only (REQ-17)", () => {
}
}, 55_000);
});
// The engine's terminal signal arrived on the stream: onDone only
// fires after the run_done event (the stream closes on it).
expect(done).toBe(true);
expect(events).toContain("status");
const final = await client.getRun(run.id);
expect(final.status).toBe("done");
// P-6 (registry pruning, T-1.8): the terminal run's registry entry
// is dropped once its last SSE subscriber leaves. A run GET after
// the stream 404s — documented acceptable; the trace persists on
// disk (the events-404-after-prune class, pinned here end to end).
await expect(client.getRun(run.id)).rejects.toThrow(/failed to load run/);
expect(userActions).toBeLessThanOrEqual(3);
}, 90_000);
+391
View File
@@ -4,6 +4,126 @@
*/
export interface paths {
"/healthz": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Healthz */
get: operations["healthz_healthz_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/register": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Register */
post: operations["register_api_auth_register_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Login */
post: operations["login_api_auth_login_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Logout */
post: operations["logout_api_auth_logout_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Me */
get: operations["me_api_auth_me_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Users */
get: operations["list_users_api_admin_users_get"];
put?: never;
/** Create User */
post: operations["create_user_api_admin_users_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/users/{user_id}/deactivate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Deactivate User */
post: operations["deactivate_user_api_admin_users__user_id__deactivate_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/config": {
parameters: {
query?: never;
@@ -195,6 +315,23 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** AdminCreateUserBody */
AdminCreateUserBody: {
/** Email */
email: string;
/** Password */
password: string;
/**
* Display Name
* @default
*/
display_name: string;
/**
* Role
* @default user
*/
role: string;
};
/** ApproveBody */
ApproveBody: {
/** Plan */
@@ -240,6 +377,13 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** LoginBody */
LoginBody: {
/** Email */
email: string;
/** Password */
password: string;
};
/** MethodologyCard */
MethodologyCard: {
/** Slug */
@@ -261,6 +405,18 @@ export interface components {
[key: string]: unknown;
};
};
/** RegisterBody */
RegisterBody: {
/** Email */
email: string;
/** Password */
password: string;
/**
* Display Name
* @default
*/
display_name: string;
};
/** RunSummary */
RunSummary: {
/** Id */
@@ -278,6 +434,27 @@ export interface components {
/** Started At */
started_at: string;
};
/**
* UserPublic
* @description A user as exposed on the wire — no password_hash, ever.
*/
UserPublic: {
/** User Id */
user_id: string;
/** Email */
email: string;
/** Display Name */
display_name: string;
/** Role */
role: string;
/** Active */
active: boolean;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** ValidationError */
ValidationError: {
/** Location */
@@ -296,6 +473,220 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
healthz_healthz_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
register_api_auth_register_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RegisterBody"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserPublic"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
login_api_auth_login_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LoginBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserPublic"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
logout_api_auth_logout_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: boolean;
};
};
};
};
};
me_api_auth_me_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"]["UserPublic"];
};
};
};
};
list_users_api_admin_users_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"]["UserPublic"][];
};
};
};
};
create_user_api_admin_users_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AdminCreateUserBody"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserPublic"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
deactivate_user_api_admin_users__user_id__deactivate_post: {
parameters: {
query?: never;
header?: never;
path: {
user_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserPublic"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
config_status_api_config_get: {
parameters: {
query?: never;
+120
View File
@@ -17,15 +17,18 @@ import createClient from "openapi-fetch";
import type { paths } from "@/lib/api/generated/schema";
import type {
CitationLedger,
CreateUserInput,
Methodology,
Report,
ResearchRun,
TraceEvent,
UserProfile,
} from "@/lib/types";
import type {
CreateRunInput,
RunEventSubscription,
} from "@/lib/api/client";
import { AuthError } from "@/lib/api/client";
import { MockApiClient } from "@/lib/api/mock/mock-client";
function baseUrl(): string {
@@ -34,6 +37,12 @@ function baseUrl(): string {
);
}
/**
* The uniform CSRF header value for every mutating request (C3-05):
* the backend guard requires `X-ResearchX-Request` on non-GET calls.
*/
const CSRF_HEADER = "X-ResearchX-Request";
type BackendRun = {
id: string;
status: string;
@@ -69,6 +78,117 @@ export class GeneratedApiClient extends MockApiClient {
});
}
/* ---------------------------------------------------------------- */
/* Session surface (T-1.10, REQ-69/REQ-70). Fetch discipline: */
/* - `credentials: "include"` on EVERY call — the session cookie */
/* rides along; */
/* - `X-ResearchX-Request: "1"` on every mutation (C3-05). */
/* ---------------------------------------------------------------- */
/** Map the backend's UserPublic to the frontend UserProfile shape. */
#toProfile(user: {
user_id: string;
email: string;
display_name: string;
role: string;
active: boolean;
created_at: string;
}): UserProfile {
return {
user_id: user.user_id,
email: user.email,
display_name: user.display_name,
role: user.role,
active: user.active,
created_at: user.created_at,
};
}
override async login(email: string, password: string): Promise<UserProfile> {
const { data, error, response } = await this.#client.POST(
"/api/auth/login",
{
body: { email, password },
credentials: "include",
headers: { [CSRF_HEADER]: "1" },
},
);
if (error || !data) {
// Uniform 401 — the backend never enumerates users; neither do we.
if (response.status === 401) {
throw new AuthError("invalid email or password", 401);
}
throw new AuthError("login failed", response.status);
}
return this.#toProfile(data);
}
override async logout(): Promise<void> {
const { error } = await this.#client.POST("/api/auth/logout", {
credentials: "include",
headers: { [CSRF_HEADER]: "1" },
});
if (error) {
throw new Error("live: logout failed");
}
}
override async getSession(): Promise<UserProfile | null> {
const { data, error } = await this.#client.GET("/api/auth/me", {
credentials: "include",
});
if (error || !data) {
// 401 → null: "no session" is a state, not an error.
return null;
}
return this.#toProfile(data);
}
override async listUsers(): Promise<UserProfile[]> {
const { data, error } = await this.#client.GET("/api/admin/users", {
credentials: "include",
});
if (error || !data) {
throw new Error("live: failed to list users");
}
return data.map((user) => this.#toProfile(user));
}
override async createUser(input: CreateUserInput): Promise<UserProfile> {
const { data, error, response } = await this.#client.POST(
"/api/admin/users",
{
body: {
email: input.email,
password: input.password,
display_name: input.display_name ?? "",
role: input.role ?? "user",
},
credentials: "include",
headers: { [CSRF_HEADER]: "1" },
},
);
if (error || !data) {
throw new Error(`live: failed to create user (${response.status})`);
}
return this.#toProfile(data);
}
override async deactivateUser(user_id: string): Promise<UserProfile> {
const { data, error } = await this.#client.POST(
"/api/admin/users/{user_id}/deactivate",
{
params: { path: { user_id } },
credentials: "include",
headers: { [CSRF_HEADER]: "1" },
},
);
if (error || !data) {
throw new Error(`live: failed to deactivate user ${user_id}`);
}
return this.#toProfile(data);
}
override async getMethodologies(): Promise<Methodology[]> {
const { data, error } = await this.#client.GET("/api/methodologies");
if (error || !data) {
+90 -1
View File
@@ -7,6 +7,7 @@
*/
import type { ApiClient, CreateRunInput } from "@/lib/api/client";
import { AuthError } from "@/lib/api/client";
import {
buildSuggestedQueries,
composeRefinedQuestion,
@@ -33,6 +34,8 @@ import {
import type {
CitationLedger,
ConnectionSlot,
CreateUserInput,
DisplayProfile,
InboxItem,
Methodology,
ProjectGraph,
@@ -274,7 +277,7 @@ export class MockApiClient implements ApiClient {
}
/** The local single-user profile behind the avatar (REQ-66). */
async getUserProfile(): Promise<UserProfile> {
async getUserProfile(): Promise<DisplayProfile> {
return structuredClone(userProfileFixture);
}
@@ -334,6 +337,92 @@ export class MockApiClient implements ApiClient {
return composeRefinedQuestion(draft, answers);
}
/* ---------------------------------------------------------------- */
/* v0.3 P1 session surface — mock-local (T-1.10, REQ-69/REQ-70). */
/* The mock stays a standalone zero-backend fixture: login accepts */
/* any non-empty credentials and swaps an in-memory session in; */
/* the dev user mirrors the backend's synthetic dev admin */
/* (DEV_USER_ID "dev00000", guard.py). */
/* ---------------------------------------------------------------- */
#session: UserProfile | null = null;
/** The mock dev user — same synthetic admin the backend's dev guard resolves. */
static readonly DEV_USER: UserProfile = {
user_id: "dev00000",
email: "dev@localhost",
display_name: "Dev User",
role: "admin",
active: true,
created_at: "2026-01-01T00:00:00Z",
};
/** Small fixture user set for the admin hooks (REQ-70). */
static readonly USERS: UserProfile[] = [
MockApiClient.DEV_USER,
{
user_id: "abcd2345",
email: "colleague@example.org",
display_name: "Fixture Colleague",
role: "user",
active: true,
created_at: "2026-02-01T00:00:00Z",
},
];
/** Local "login" — any non-empty credentials open the dev session. */
async login(email: string, password: string): Promise<UserProfile> {
if (email.trim() === "" || password === "") {
throw new AuthError();
}
this.#session = structuredClone(MockApiClient.DEV_USER);
return structuredClone(MockApiClient.DEV_USER);
}
/** Local "logout" — clears the in-memory session. */
async logout(): Promise<void> {
this.#session = null;
}
/**
* The mock session is implicitly open (dev fixture): the dev user
* when a "login" happened this session, otherwise still the dev
* user — the mock never gates the app behind a login (REQ-31).
*/
async getSession(): Promise<UserProfile | null> {
return structuredClone(this.#session ?? MockApiClient.DEV_USER);
}
/** Admin hooks — small fixture arrays (REQ-70). */
async listUsers(): Promise<UserProfile[]> {
return structuredClone(MockApiClient.USERS);
}
async createUser(input: CreateUserInput): Promise<UserProfile> {
const created: UserProfile = {
user_id: "newu0001",
email: input.email,
display_name: input.display_name ?? input.email.split("@")[0] ?? "New User",
role: input.role ?? "user",
active: true,
created_at: "2026-09-29T00:00:00Z",
};
MockApiClient.USERS.push(created);
return structuredClone(created);
}
async deactivateUser(user_id: string): Promise<UserProfile> {
const user = MockApiClient.USERS.find((u) => u.user_id === user_id);
if (!user) {
throw new Error(`mock: unknown user ${user_id}`);
}
user.active = false;
if (this.#session?.user_id === user_id) {
this.#session = null; // deactivation kills sessions
}
return structuredClone(user);
}
/** The scripted run for the prototype's demo question. */
#defaultRun(): ResearchRun {
return buildScriptedRun({
+2 -2
View File
@@ -9,9 +9,9 @@
import type { Settings } from "@/lib/types";
import type {
DisplayProfile,
ReportConnection,
ReportLibrary,
UserProfile,
} from "@/lib/types";
/* ------------------------------------------------------------------ */
@@ -200,7 +200,7 @@ export const connectionStatusFixture: ConnectionSlot[] = [
/* User profile (REQ-66) */
/* ------------------------------------------------------------------ */
export const userProfileFixture: UserProfile = {
export const userProfileFixture: DisplayProfile = {
name: "Researcher",
initials: "RS",
tagline: "Local workspace — single user",
@@ -0,0 +1,255 @@
/**
* Session-surface boundary tests (T-1.10, REQ-69, REQ-70).
*
* The v0.3 P1 ApiClient additions — login / logout / getSession plus
* the admin hooks — contract-tested against BOTH bindings:
* - the mock binding (standalone, zero backend — REQ-31/REQ-54), and
* - the live binding against a stubbed global fetch (no network, no
* keys), asserting the real routes, the CSRF header on every
* mutation (C3-05), and credentials:"include" cookie discipline.
*/
import { describe, expect, it } from "vitest";
import { AuthError, type ApiClient } from "@/lib/api/client";
import { MockApiClient } from "@/lib/api/mock/mock-client";
import { GeneratedApiClient } from "@/lib/api/live-client";
/* ------------------------------------------------------------------ */
/* Mock binding — standalone session fixture */
/* ------------------------------------------------------------------ */
describe("MockApiClient session surface (REQ-69/REQ-70)", () => {
it("login accepts any non-empty credentials and returns the dev admin", async () => {
const client: ApiClient = new MockApiClient();
const user = await client.login("dev@localhost", "anything");
expect(user.user_id).toBe("dev00000");
expect(user.role).toBe("admin");
expect(user.active).toBe(true);
});
it("getSession returns the dev user (mock is never login-gated)", async () => {
const client: ApiClient = new MockApiClient();
const session = await client.getSession();
expect(session?.user_id).toBe("dev00000");
});
it("logout clears the session; getSession still reports the dev fixture", async () => {
const client: ApiClient = new MockApiClient();
await client.login("dev@localhost", "pw");
await client.logout();
// The mock dev user persists as the implicit session (REQ-31)…
expect((await client.getSession())?.user_id).toBe("dev00000");
});
it("login rejects empty credentials with a typed AuthError", async () => {
const client: ApiClient = new MockApiClient();
await expect(client.login("", "")).rejects.toBeInstanceOf(AuthError);
});
it("admin hooks return small fixture arrays and honor deactivate", async () => {
const client: ApiClient = new MockApiClient();
const users = await client.listUsers();
expect(users.length).toBeGreaterThanOrEqual(2);
expect(users[0]?.role).toBe("admin");
const created = await client.createUser({
email: "new@example.org",
password: "not-a-real-secret",
});
expect(created.email).toBe("new@example.org");
expect(created.active).toBe(true);
const deactivated = await client.deactivateUser(created.user_id);
expect(deactivated.active).toBe(false);
});
});
/* ------------------------------------------------------------------ */
/* Live binding — real routes against a stubbed fetch */
/* ------------------------------------------------------------------ */
type RouteHandler = (url: string, init?: RequestInit) => { status: number; body: unknown };
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
// openapi-fetch builds a Request and hands it to fetch — surface the
// request's own headers + credentials back to the route handler so
// tests can assert the CSRF header and cookie discipline per call.
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : undefined;
const url = request ? request.url : String(input);
const sentInit: RequestInit | undefined = request
? {
...init,
method: request.method,
credentials: request.credentials,
headers: request.headers,
}
: init;
for (const [prefix, handler] of Object.entries(routes)) {
if (url.startsWith(prefix)) {
const { status, body } = handler(url, sentInit);
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
}
return new Response(JSON.stringify(body404()), { status: 404 });
}) as typeof globalThis.fetch;
}
const body404 = (): unknown => ({ detail: "not found" });
const backendUser = {
user_id: "abcd2345",
email: "owner@example.org",
display_name: "Owner",
role: "admin",
active: true,
created_at: "2026-09-29T12:00:00Z",
};
/** Headers actually sent on the last mutating call (CSRF + cookie assertions). */
function mutationCapture(): {
headersOf: (init?: RequestInit) => Record<string, string>;
} {
return {
// Duck-typed: jsdom + Node fetch disagree on the Headers realm, so
// `instanceof Headers` is false across the boundary — check shape.
headersOf: (init?: RequestInit) => {
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;
},
};
}
describe("GeneratedApiClient session surface (live binding, REQ-69/REQ-70)", () => {
it("login POSTs /api/auth/login with the CSRF header + credentials", async () => {
const { headersOf } = mutationCapture();
const routes = {
"http://127.0.0.1:8000/api/auth/login": (_url: string, init?: RequestInit) => {
const headers = headersOf(init);
expect(headers["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
return { status: 200, body: backendUser };
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
const user = await client.login("owner@example.org", "correct-horse");
expect(user.user_id).toBe("abcd2345");
expect(user.email).toBe("owner@example.org");
expect(user.role).toBe("admin");
});
it("login 401 → typed AuthError with the uniform message", async () => {
const routes = {
"http://127.0.0.1:8000/api/auth/login": () => ({
status: 401,
body: { detail: "invalid email or password" },
}),
};
const client = new GeneratedApiClient(stubRoutes(routes));
const error = await client.login("owner@example.org", "wrong").catch((e: unknown) => e);
expect(error).toBeInstanceOf(AuthError);
expect((error as AuthError).status).toBe(401);
expect((error as AuthError).message).toBe("invalid email or password");
});
it("logout POSTs /api/auth/logout with the CSRF header", async () => {
const { headersOf } = mutationCapture();
const routes = {
"http://127.0.0.1:8000/api/auth/logout": (_url: string, init?: RequestInit) => {
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
return { status: 200, body: { ok: true } };
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
await expect(client.logout()).resolves.toBeUndefined();
});
it("getSession maps 200 → UserProfile; 401 → null (never a throw)", async () => {
const ok = new GeneratedApiClient(
stubRoutes({
"http://127.0.0.1:8000/api/auth/me": () => ({
status: 200,
body: backendUser,
}),
}),
);
expect((await ok.getSession())?.user_id).toBe("abcd2345");
const unauthed = new GeneratedApiClient(
stubRoutes({
"http://127.0.0.1:8000/api/auth/me": () => ({
status: 401,
body: { detail: "authentication required" },
}),
}),
);
expect(await unauthed.getSession()).toBeNull();
});
it("admin hooks hit the protected routes with CSRF on mutations", async () => {
const { headersOf } = mutationCapture();
const routes: Record<string, RouteHandler> = {
// Registered BEFORE the collection route: the deactivate path
// startswith-matches "/api/admin/users" too.
"http://127.0.0.1:8000/api/admin/users/abcd2345/deactivate": (
_url: string,
init?: RequestInit,
) => {
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
return { status: 200, body: { ...backendUser, active: false } };
},
"http://127.0.0.1:8000/api/admin/users": (url: string, init?: RequestInit) => {
if (url.endsWith("/api/admin/users") && init?.method !== "POST") {
return { status: 200, body: [backendUser] };
}
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
return {
status: 201,
body: {
...backendUser,
user_id: "newaaa01",
email: "second@example.org",
role: "user",
},
};
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
const users = await client.listUsers();
expect(users).toHaveLength(1);
expect(users[0]?.user_id).toBe("abcd2345");
const created = await client.createUser({
email: "second@example.org",
password: "not-a-real-secret",
role: "user",
});
expect(created.user_id).toBe("newaaa01");
expect(created.role).toBe("user");
const deactivated = await client.deactivateUser("abcd2345");
expect(deactivated.active).toBe(false);
});
});
+35 -3
View File
@@ -397,9 +397,13 @@ export interface Settings {
theme: "paper";
}
/** The local single-user profile behind the avatar button (REQ-66). */
export interface UserProfile {
/** Display name — local profile, no auth (single-user constraint). */
/**
* The menu/display profile behind the avatar button (REQ-66).
* v0.2 prototype shape — name + initials + tagline for the local
* single-user workspace. Kept for the getUserProfile() surface.
*/
export interface DisplayProfile {
/** Display name. */
name: string;
/** Two-letter initials rendered inside the avatar disc. */
initials: string;
@@ -407,6 +411,34 @@ export interface UserProfile {
tagline: string;
}
/* ------------------------------------------------------------------ */
/* Auth session (P1 vocabulary — REQ-69, REQ-70) */
/* ------------------------------------------------------------------ */
/**
* The authenticated user as the wire exposes them (T-1.10, REQ-69).
* Mirrors the backend's UserPublic: no password hash, ever.
*/
export interface UserProfile {
/** 8-char backend slug, e.g. "dev00000". */
user_id: string;
email: string;
display_name: string;
/** "admin" | "user". */
role: string;
active: boolean;
/** ISO timestamp of account creation. */
created_at: string;
}
/** Input to the admin user-create hook (REQ-70). */
export interface CreateUserInput {
email: string;
password: string;
display_name?: string;
role?: "admin" | "user";
}
/* ------------------------------------------------------------------ */
/* Ask intelligence (P7 vocabulary — REQ-67, REQ-68) */
/* ------------------------------------------------------------------ */