diff --git a/backend/.gitignore b/backend/.gitignore index 2cde43b..515913b 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -2,4 +2,5 @@ __pycache__/ .pytest_cache/ .ruff_cache/ -.mypy_cache/ \ No newline at end of file +.mypy_cache/ +var/ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 83b2970..2084bc4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/backend/src/researchx/adapters/local_storage.py b/backend/src/researchx/adapters/local_storage.py index f42f877..337d14a 100644 --- a/backend/src/researchx/adapters/local_storage.py +++ b/backend/src/researchx/adapters/local_storage.py @@ -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`` 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) diff --git a/backend/src/researchx/adapters/storage.py b/backend/src/researchx/adapters/storage.py index 3c9859b..255cdd0 100644 --- a/backend/src/researchx/adapters/storage.py +++ b/backend/src/researchx/adapters/storage.py @@ -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: ... \ No newline at end of file diff --git a/backend/src/researchx/api/app.py b/backend/src/researchx/api/app.py index ffc4271..0545e24 100644 --- a/backend/src/researchx/api/app.py +++ b/backend/src/researchx/api/app.py @@ -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///.""" + 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() \ No newline at end of file +# 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() diff --git a/backend/src/researchx/api/dev_bridge.py b/backend/src/researchx/api/dev_bridge.py index d63f7af..213f716 100644 --- a/backend/src/researchx/api/dev_bridge.py +++ b/backend/src/researchx/api/dev_bridge.py @@ -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") diff --git a/backend/src/researchx/api/sweep.py b/backend/src/researchx/api/sweep.py new file mode 100644 index 0000000..b062ab3 --- /dev/null +++ b/backend/src/researchx/api/sweep.py @@ -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///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 \ No newline at end of file diff --git a/backend/src/researchx/auth/__init__.py b/backend/src/researchx/auth/__init__.py new file mode 100644 index 0000000..b3628d5 --- /dev/null +++ b/backend/src/researchx/auth/__init__.py @@ -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", +] \ No newline at end of file diff --git a/backend/src/researchx/auth/admin_routes.py b/backend/src/researchx/auth/admin_routes.py new file mode 100644 index 0000000..ec2b87a --- /dev/null +++ b/backend/src/researchx/auth/admin_routes.py @@ -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 \ No newline at end of file diff --git a/backend/src/researchx/auth/guard.py b/backend/src/researchx/auth/guard.py new file mode 100644 index 0000000..febc93e --- /dev/null +++ b/backend/src/researchx/auth/guard.py @@ -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", +] \ No newline at end of file diff --git a/backend/src/researchx/auth/models.py b/backend/src/researchx/auth/models.py new file mode 100644 index 0000000..684b6fe --- /dev/null +++ b/backend/src/researchx/auth/models.py @@ -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, + ) \ No newline at end of file diff --git a/backend/src/researchx/auth/routes.py b/backend/src/researchx/auth/routes.py new file mode 100644 index 0000000..5edb0be --- /dev/null +++ b/backend/src/researchx/auth/routes.py @@ -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 \ No newline at end of file diff --git a/backend/src/researchx/auth/sessions.py b/backend/src/researchx/auth/sessions.py new file mode 100644 index 0000000..7da20aa --- /dev/null +++ b/backend/src/researchx/auth/sessions.py @@ -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 ``/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) \ No newline at end of file diff --git a/backend/src/researchx/auth/users.py b/backend/src/researchx/auth/users.py new file mode 100644 index 0000000..18bf935 --- /dev/null +++ b/backend/src/researchx/auth/users.py @@ -0,0 +1,203 @@ +"""User store — durable JSON-backed account registry (REQ-69, D-102/D-105). + +Persisted to ``/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 \ No newline at end of file diff --git a/backend/src/researchx/config/ids.py b/backend/src/researchx/config/ids.py new file mode 100644 index 0000000..085f3eb --- /dev/null +++ b/backend/src/researchx/config/ids.py @@ -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 \ No newline at end of file diff --git a/backend/src/researchx/config/paths.py b/backend/src/researchx/config/paths.py new file mode 100644 index 0000000..bf5b18b --- /dev/null +++ b/backend/src/researchx/config/paths.py @@ -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 \ No newline at end of file diff --git a/backend/src/researchx/config/run_config.py b/backend/src/researchx/config/run_config.py index 79c8ccd..1425429 100644 --- a/backend/src/researchx/config/run_config.py +++ b/backend/src/researchx/config/run_config.py @@ -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"] \ No newline at end of file diff --git a/backend/src/researchx/engine/actor.py b/backend/src/researchx/engine/actor.py index 7b54f9e..0f99b24 100644 --- a/backend/src/researchx/engine/actor.py +++ b/backend/src/researchx/engine/actor.py @@ -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 \ No newline at end of file diff --git a/backend/src/researchx/engine/demo.py b/backend/src/researchx/engine/demo.py index 155af50..f7cb770 100644 --- a/backend/src/researchx/engine/demo.py +++ b/backend/src/researchx/engine/demo.py @@ -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, diff --git a/backend/src/researchx/engine/run_state.py b/backend/src/researchx/engine/run_state.py index 93e23a3..ad1f82a 100644 --- a/backend/src/researchx/engine/run_state.py +++ b/backend/src/researchx/engine/run_state.py @@ -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 diff --git a/backend/src/researchx/engine/states.py b/backend/src/researchx/engine/states.py index 0c76671..e13ea52 100644 --- a/backend/src/researchx/engine/states.py +++ b/backend/src/researchx/engine/states.py @@ -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, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..f511fd7 --- /dev/null +++ b/backend/tests/conftest.py @@ -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 \ No newline at end of file diff --git a/backend/tests/test_adversarial.py b/backend/tests/test_adversarial.py index 69e7b55..6f0539a 100644 --- a/backend/tests/test_adversarial.py +++ b/backend/tests/test_adversarial.py @@ -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" diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index cf25f79..d419d11 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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, ) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..078e9bd --- /dev/null +++ b/backend/tests/test_auth.py @@ -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///" + 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 \ No newline at end of file diff --git a/backend/tests/test_engine.py b/backend/tests/test_engine.py index 7710ca4..07aea40 100644 --- a/backend/tests/test_engine.py +++ b/backend/tests/test_engine.py @@ -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" diff --git a/backend/tests/test_integration_p1.py b/backend/tests/test_integration_p1.py index 7aa3a25..93cd59c 100644 --- a/backend/tests/test_integration_p1.py +++ b/backend/tests/test_integration_p1.py @@ -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: diff --git a/backend/tests/test_local_storage.py b/backend/tests/test_local_storage.py index f9a336b..86f4f17 100644 --- a/backend/tests/test_local_storage.py +++ b/backend/tests/test_local_storage.py @@ -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/// 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 == [] \ No newline at end of file + 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 \ No newline at end of file diff --git a/backend/tests/test_p0_regressions.py b/backend/tests/test_p0_regressions.py index b225b71..b30f47f 100644 --- a/backend/tests/test_p0_regressions.py +++ b/backend/tests/test_p0_regressions.py @@ -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" diff --git a/backend/tests/test_probe_wiring.py b/backend/tests/test_probe_wiring.py index c4b16e0..301d347 100644 --- a/backend/tests/test_probe_wiring.py +++ b/backend/tests/test_probe_wiring.py @@ -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, ) diff --git a/backend/tests/test_resume.py b/backend/tests/test_resume.py index f5eba7b..bc755fa 100644 --- a/backend/tests/test_resume.py +++ b/backend/tests/test_resume.py @@ -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, diff --git a/backend/tests/test_sweep_prune.py b/backend/tests/test_sweep_prune.py new file mode 100644 index 0000000..36bddfc --- /dev/null +++ b/backend/tests/test_sweep_prune.py @@ -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 \ No newline at end of file diff --git a/backend/tests/test_users_sessions.py b/backend/tests/test_users_sessions.py new file mode 100644 index 0000000..2bda15c --- /dev/null +++ b/backend/tests/test_users_sessions.py @@ -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 \ No newline at end of file diff --git a/backend/uv.lock b/backend/uv.lock index fa84be1..1018282 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" }, diff --git a/frontend/README.md b/frontend/README.md index b0e7ba1..85408ad 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 diff --git a/frontend/openapi.json b/frontend/openapi.json index d67367f..a34d5aa 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -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 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/package.json b/frontend/package.json index e512fa1..92a68f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 907c97e..96b8b95 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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
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 ( -
- -
- -
{children}
+ +
+ +
+ +
{children}
+
-
+ ); diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..1ad0d84 --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -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 ; +} \ No newline at end of file diff --git a/frontend/src/components/app-shell.test.tsx b/frontend/src/components/app-shell.test.tsx index 8d1e72c..3d169dc 100644 --- a/frontend/src/components/app-shell.test.tsx +++ b/frontend/src/components/app-shell.test.tsx @@ -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( + {}} 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(); + }); + }); }); \ No newline at end of file diff --git a/frontend/src/components/app-top-bar.tsx b/frontend/src/components/app-top-bar.tsx index 54d29e6..019102f 100644 --- a/frontend/src/components/app-top-bar.tsx +++ b/frontend/src/components/app-top-bar.tsx @@ -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 void; }>(function AvatarButton({ menuOpen, onToggle }, ref) { - const [profile, setProfile] = useState(null); + const [profile, setProfile] = useState(null); useEffect(() => { let cancelled = false; api diff --git a/frontend/src/components/login-view.test.tsx b/frontend/src/components/login-view.test.tsx new file mode 100644 index 0000000..b4b72b5 --- /dev/null +++ b/frontend/src/components/login-view.test.tsx @@ -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 + >(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/login-view.tsx b/frontend/src/components/login-view.tsx new file mode 100644 index 0000000..4e6f1cb --- /dev/null +++ b/frontend/src/components/login-view.tsx @@ -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(null); + + const canSubmit = email.trim() !== "" && password !== "" && !submitting; + + function handleSubmit(event: FormEvent) { + 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 ( +
+
+

Sign in.

+

+ ResearchX runs under your account — your research library, + reports, and settings are yours alone. +

+
+ +
+ + 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" + /> + + + 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 && ( +

+ {error} +

+ )} + + +
+ +

+ First run? The first account created becomes the administrator. +

+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/session-gate.test.tsx b/frontend/src/components/session-gate.test.tsx new file mode 100644 index 0000000..68d3424 --- /dev/null +++ b/frontend/src/components/session-gate.test.tsx @@ -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 + >(); + 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( + +

Protected screen

+
, + ); + // 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( + +

Protected screen

+
, + ); + 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( + +

Protected screen

+
, + ); + await vi.waitFor(() => { + expect(replaceMock).toHaveBeenCalledWith("/login"); + }); + expect(screen.queryByText("Protected screen")).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/session-gate.tsx b/frontend/src/components/session-gate.tsx new file mode 100644 index 0000000..d73ee52 --- /dev/null +++ b/frontend/src/components/session-gate.tsx @@ -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 ( +
+

Loading…

+
+ ); + } + return <>{children}; +} \ No newline at end of file diff --git a/frontend/src/components/user-menu-session.test.tsx b/frontend/src/components/user-menu-session.test.tsx new file mode 100644 index 0000000..75542f3 --- /dev/null +++ b/frontend/src/components/user-menu-session.test.tsx @@ -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, + liveMode: false, +})); +vi.mock("@/lib/api/client", async (importOriginal) => { + const actual = await importOriginal< + Promise + >(); + 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; +}): 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; + state.liveMode = true; + render( {}} 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; + state.liveMode = false; + render( {}} 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; + state.liveMode = true; + const user = userEvent.setup(); + render( {}} 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; + state.liveMode = true; + const user = userEvent.setup(); + render( {}} onDismiss={() => {}} />); + + await user.click( + await screen.findByRole("menuitem", { name: /Log out/i }), + ); + await vi.waitFor(() => { + expect(pushMock).toHaveBeenCalledWith("/login"); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/user-menu.tsx b/frontend/src/components/user-menu.tsx index a463eef..0020a9e 100644 --- a/frontend/src/components/user-menu.tsx +++ b/frontend/src/components/user-menu.tsx @@ -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; }) { - const [profile, setProfile] = useState(null); + const router = useRouter(); + const [profile, setProfile] = useState(null); + const [session, setSession] = useState(null); + const [loggingOut, setLoggingOut] = useState(false); const menuRef = useRef(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}
-

- {profile?.name ?? "Researcher"} -

-

- {profile?.tagline ?? "Local workspace"} -

+

{name}

+

{detail}

@@ -105,11 +148,33 @@ export function UserMenu({ About & version +
  • + +
  • - 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."}

    ); +} + +/** 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(); } \ No newline at end of file diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index e43c375..cf52bb7 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -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; - /** The local single-user profile behind the avatar (REQ-66). */ - getUserProfile(): Promise; + /** + * 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; /** Adapter-slot connection status for the shell + settings (REQ-65). */ getConnectionStatus(): Promise; @@ -191,8 +198,68 @@ export interface ApiClient { draft: string, answers: Record, ): Promise; + + /* ---------------------------------------------------------------- */ + /* 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; + + /** Revoke the current session (POST /api/auth/logout). */ + logout(): Promise; + + /** + * The current session's user, or null when unauthenticated + * (GET /api/auth/me — 401 maps to null, never a throw). + */ + getSession(): Promise; + + /** Admin hook: list all users (GET /api/admin/users — REQ-70). */ + listUsers(): Promise; + + /** Admin hook: create a user with an admin-set password (POST /api/admin/users). */ + createUser(input: CreateUserInput): Promise; + + /** + * Admin hook: deactivate a user — kills their sessions, retains + * their data (POST /api/admin/users/{user_id}/deactivate). + */ + deactivateUser(user_id: string): Promise; } +/** + * 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 diff --git a/frontend/src/lib/api/e2e-real-stack.test.ts b/frontend/src/lib/api/e2e-real-stack.test.ts index 020753d..e8ee03d 100644 --- a/frontend/src/lib/api/e2e-real-stack.test.ts +++ b/frontend/src/lib/api/e2e-real-stack.test.ts @@ -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); diff --git a/frontend/src/lib/api/generated/schema.d.ts b/frontend/src/lib/api/generated/schema.d.ts index 0847a04..6e8adca 100644 --- a/frontend/src/lib/api/generated/schema.d.ts +++ b/frontend/src/lib/api/generated/schema.d.ts @@ -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; 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; 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; diff --git a/frontend/src/lib/api/live-client.ts b/frontend/src/lib/api/live-client.ts index e447b9a..6fdb87d 100644 --- a/frontend/src/lib/api/live-client.ts +++ b/frontend/src/lib/api/live-client.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { const { data, error } = await this.#client.GET("/api/methodologies"); if (error || !data) { diff --git a/frontend/src/lib/api/mock/mock-client.ts b/frontend/src/lib/api/mock/mock-client.ts index 1681cc0..737772c 100644 --- a/frontend/src/lib/api/mock/mock-client.ts +++ b/frontend/src/lib/api/mock/mock-client.ts @@ -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 { + async getUserProfile(): Promise { 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 { + 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 { + 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 { + return structuredClone(this.#session ?? MockApiClient.DEV_USER); + } + + /** Admin hooks — small fixture arrays (REQ-70). */ + async listUsers(): Promise { + return structuredClone(MockApiClient.USERS); + } + + async createUser(input: CreateUserInput): Promise { + 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 { + 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({ diff --git a/frontend/src/lib/api/mock/shell-fixtures.ts b/frontend/src/lib/api/mock/shell-fixtures.ts index 8afab30..99d1d1c 100644 --- a/frontend/src/lib/api/mock/shell-fixtures.ts +++ b/frontend/src/lib/api/mock/shell-fixtures.ts @@ -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", diff --git a/frontend/src/lib/api/session-boundary.test.ts b/frontend/src/lib/api/session-boundary.test.ts new file mode 100644 index 0000000..d3d136f --- /dev/null +++ b/frontend/src/lib/api/session-boundary.test.ts @@ -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): 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; +} { + 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 = {}; + 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 = { + // 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); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d631c6d..a47e660 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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) */ /* ------------------------------------------------------------------ */