feat(P01): auth + sessions + user scoping (#17)
Squash-merge of phase/01-auth-sessions-scoping into milestone/v0.3-hosted. REQ-69/70/71 covered. Auth package (argon2id users, server-side sessions, guard+CSRF, admin routes), user-scoped storage partitioning, owner-keyed registry, restart sweep (G-101), registry pruning, frontend session surface (login, gate, user menu, ApiClient extensions), openapi snapshot regen + drift gate. Security: P0 register race + P1 corrupt-store fail-closed fixed in-phase. Verify: PASS 4/4 layers, 184+119 tests. Ships as v0.3.1.
This commit was merged in pull request #17.
This commit is contained in:
+2
-1
@@ -2,4 +2,5 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.mypy_cache/
|
||||
var/
|
||||
|
||||
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"uvicorn>=0.30,<1",
|
||||
"sse-starlette>=2.1,<3",
|
||||
"httpx>=0.27,<1",
|
||||
"argon2-cffi>=23.1,<26",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -5,19 +5,27 @@ import secrets as secrets_module
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.adapters.types import AdapterError
|
||||
from researchx.config.ids import SLUG_RE, generate_slug
|
||||
|
||||
_RUN_ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
_RUN_ID_RE = re.compile(r"^[a-z0-9]{8}$")
|
||||
_ARTIFACT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
|
||||
def generate_run_id() -> str:
|
||||
return "".join(secrets_module.choice(_RUN_ID_ALPHABET) for _ in range(8))
|
||||
"""Generate an 8-char run id (consolidated generator — config/ids.py, P-14)."""
|
||||
return generate_slug()
|
||||
|
||||
|
||||
def _validate_slug(value: str, what: str) -> None:
|
||||
if SLUG_RE.match(value) is None:
|
||||
raise AdapterError(f"invalid {what}")
|
||||
|
||||
|
||||
def _validate_run_id(run_id: str) -> None:
|
||||
if not _RUN_ID_RE.match(run_id):
|
||||
raise AdapterError("invalid run id")
|
||||
_validate_slug(run_id, "run id")
|
||||
|
||||
|
||||
def _validate_owner_id(owner_id: str) -> None:
|
||||
_validate_slug(owner_id, "owner id")
|
||||
|
||||
|
||||
def _validate_artifact_name(name: str) -> None:
|
||||
@@ -26,12 +34,41 @@ def _validate_artifact_name(name: str) -> None:
|
||||
|
||||
|
||||
class LocalStorageAdapter:
|
||||
def __init__(self, base_dir: Path) -> None:
|
||||
"""Owner-scoped local run storage (D-106): runs/<owner_id>/<run_id>/.
|
||||
|
||||
``owner_id`` is REQUIRED (an optional default invites silent
|
||||
unpartitioned use — RESEARCH §5 pitfall) and validated against the
|
||||
shared 8-char slug shape (D-105) so it is a filesystem-safe
|
||||
partition key.
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: Path, owner_id: str) -> None:
|
||||
_validate_owner_id(owner_id)
|
||||
self._base_dir = base_dir
|
||||
self._owner_id = owner_id
|
||||
|
||||
@property
|
||||
def owner_id(self) -> str:
|
||||
return self._owner_id
|
||||
|
||||
@property
|
||||
def base_dir(self) -> Path:
|
||||
return self._base_dir
|
||||
|
||||
def run_dir(self, run_id: str) -> Path:
|
||||
_validate_run_id(run_id)
|
||||
return self._base_dir / "runs" / run_id
|
||||
return self._base_dir / "runs" / self._owner_id / run_id
|
||||
|
||||
def list_run_ids(self) -> list[str]:
|
||||
"""Scan this owner's partition, skipping non-directories/dotfiles."""
|
||||
runs_dir = self._base_dir / "runs" / self._owner_id
|
||||
if not runs_dir.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
child.name
|
||||
for child in runs_dir.iterdir()
|
||||
if child.is_dir() and not child.name.startswith(".")
|
||||
)
|
||||
|
||||
async def write_run_artifact(self, run_id: str, name: str, content: str) -> Path:
|
||||
_validate_artifact_name(name)
|
||||
@@ -58,7 +95,9 @@ class LocalStorageAdapter:
|
||||
try:
|
||||
return target.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
raise AdapterError(f"artifact not found: runs/{run_id}/{name}") from exc
|
||||
raise AdapterError(
|
||||
f"artifact not found: runs/{self._owner_id}/{run_id}/{name}"
|
||||
) from exc
|
||||
|
||||
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path:
|
||||
_validate_artifact_name(name)
|
||||
|
||||
@@ -8,4 +8,6 @@ class StorageAdapter(Protocol):
|
||||
|
||||
async def read_run_artifact(self, run_id: str, name: str) -> str: ...
|
||||
|
||||
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path: ...
|
||||
|
||||
def run_dir(self, run_id: str) -> Path: ...
|
||||
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
@@ -12,10 +12,17 @@ from researchx.adapters.capability_probe import probe_model_adapter, supports_pr
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.types import AdapterError, ChatResult
|
||||
from researchx.auth.admin_routes import build_admin_router
|
||||
from researchx.auth.guard import Guard, build_admin_guard, build_dev_guard, build_guard
|
||||
from researchx.auth.models import UserPublic
|
||||
from researchx.auth.routes import build_auth_router
|
||||
from researchx.auth.sessions import SessionStore
|
||||
from researchx.auth.users import UserStore
|
||||
from researchx.config.paths import data_root
|
||||
from researchx.engine.actor import RunActor, make_run_config
|
||||
from researchx.engine.events import EventBus, RunEvent
|
||||
from researchx.engine.run_state import Plan, RunState
|
||||
from researchx.engine.states import RunStateName
|
||||
from researchx.engine.states import TERMINAL_STATES, RunStateName
|
||||
from researchx.methodologies import registry
|
||||
|
||||
_CANNED_PLAN = (
|
||||
@@ -27,6 +34,8 @@ _CANNED_WRITER = (
|
||||
"The verified core finding [p-1]. A second corroborated observation [p-2].\n"
|
||||
)
|
||||
|
||||
_CORS_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
|
||||
|
||||
class CreateRunBody(BaseModel):
|
||||
question: str = Field(min_length=1, max_length=2000)
|
||||
@@ -84,11 +93,8 @@ _ETA_TABLE = {"rapid-answer": (2, "low"), "deep-report": (12, "medium")}
|
||||
|
||||
|
||||
def _cost_estimate(slug: str) -> str:
|
||||
depth, breadth = 1, 1
|
||||
methodology = registry.get(slug)
|
||||
depth = methodology.dials.depth
|
||||
breadth = methodology.dials.breadth
|
||||
units = depth * breadth
|
||||
units = methodology.dials.depth * methodology.dials.breadth
|
||||
if units <= 1:
|
||||
return "~5k tokens (estimate)"
|
||||
if units <= 4:
|
||||
@@ -104,33 +110,108 @@ def _why_recommended(slug: str) -> str:
|
||||
|
||||
|
||||
class RunRegistry:
|
||||
"""In-memory live-run registry, keyed (owner_id, run_id) (REQ-71).
|
||||
|
||||
Cross-owner access is indistinguishable from a missing run: the
|
||||
keyed lookup misses, routes translate the miss to a 404. Single
|
||||
uvicorn worker only — this state is single-process by design.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._actors: dict[str, tuple[RunActor, asyncio.Task[RunState]]] = {}
|
||||
self._buses: dict[str, EventBus] = {}
|
||||
self.cost_counters: dict[str, int] = {"runs_created": 0, "runs_completed": 0}
|
||||
self._actors: dict[tuple[str, str], tuple[RunActor, asyncio.Task[RunState]]] = {}
|
||||
self._buses: dict[tuple[str, str], EventBus] = {}
|
||||
# Live SSE subscriber refcounts, keyed like _actors (P-6).
|
||||
self._subscribers: dict[tuple[str, str], int] = {}
|
||||
# Owner-keyed cost counters (P-13): per-user usage surface.
|
||||
self.cost_counters: dict[str, dict[str, int]] = {}
|
||||
|
||||
def add(self, run_id: str, actor: RunActor, task: asyncio.Task[RunState], bus: EventBus) -> None:
|
||||
self._actors[run_id] = (actor, task)
|
||||
self._buses[run_id] = bus
|
||||
self.cost_counters["runs_created"] += 1
|
||||
def _counters(self, owner_id: str) -> dict[str, int]:
|
||||
return self.cost_counters.setdefault(owner_id, {"runs_created": 0, "runs_completed": 0})
|
||||
|
||||
def actor(self, run_id: str) -> RunActor:
|
||||
entry = self._actors.get(run_id)
|
||||
def add(
|
||||
self,
|
||||
owner_id: str,
|
||||
run_id: str,
|
||||
actor: RunActor,
|
||||
task: asyncio.Task[RunState],
|
||||
bus: EventBus,
|
||||
) -> None:
|
||||
self._actors[(owner_id, run_id)] = (actor, task)
|
||||
self._buses[(owner_id, run_id)] = bus
|
||||
self._counters(owner_id)["runs_created"] += 1
|
||||
|
||||
def actor(self, owner_id: str, run_id: str) -> RunActor:
|
||||
entry = self._actors.get((owner_id, run_id))
|
||||
if entry is None:
|
||||
raise KeyError(run_id)
|
||||
raise KeyError((owner_id, run_id))
|
||||
return entry[0]
|
||||
|
||||
def bus(self, run_id: str) -> EventBus:
|
||||
bus = self._buses.get(run_id)
|
||||
def bus(self, owner_id: str, run_id: str) -> EventBus:
|
||||
bus = self._buses.get((owner_id, run_id))
|
||||
if bus is None:
|
||||
raise KeyError(run_id)
|
||||
raise KeyError((owner_id, run_id))
|
||||
return bus
|
||||
|
||||
def has(self, run_id: str) -> bool:
|
||||
return run_id in self._actors
|
||||
def has(self, owner_id: str, run_id: str) -> bool:
|
||||
return (owner_id, run_id) in self._actors
|
||||
|
||||
def note_completed(self) -> None:
|
||||
self.cost_counters["runs_completed"] += 1
|
||||
def note_completed(self, owner_id: str) -> None:
|
||||
self._counters(owner_id)["runs_completed"] += 1
|
||||
|
||||
def completed_snapshot(self, owner_id: str) -> dict[str, int]:
|
||||
"""Current counters for *owner_id*, derived where possible.
|
||||
|
||||
``runs_completed`` is computed from the live registry (a run is
|
||||
completed iff its actor sits in a terminal state) and merged
|
||||
with the sticky counter — which keeps counting runs that have
|
||||
since been pruned from the registry. Deriving from actor state
|
||||
removes the scheduling race between the actor finishing and a
|
||||
detached watcher task incrementing the counter.
|
||||
"""
|
||||
counters = dict(self._counters(owner_id))
|
||||
live_done = sum(
|
||||
1
|
||||
for (owner, _), (actor, _) in self._actors.items()
|
||||
if owner == owner_id and actor.state.state == RunStateName.DONE
|
||||
)
|
||||
if live_done:
|
||||
# Prefer live truth when registry entries still exist; the
|
||||
# sticky counter catches pruned/evicted runs afterwards.
|
||||
counters["runs_completed"] = max(counters["runs_completed"], live_done)
|
||||
return counters
|
||||
|
||||
def subscribe_events(self, owner_id: str, run_id: str) -> None:
|
||||
self._subscribers[(owner_id, run_id)] = self._subscribers.get((owner_id, run_id), 0) + 1
|
||||
|
||||
def unsubscribe_events(self, owner_id: str, run_id: str) -> None:
|
||||
count = self._subscribers.get((owner_id, run_id), 0)
|
||||
if count <= 1:
|
||||
self._subscribers.pop((owner_id, run_id), None)
|
||||
else:
|
||||
self._subscribers[(owner_id, run_id)] = count - 1
|
||||
|
||||
def subscriber_count(self, owner_id: str, run_id: str) -> int:
|
||||
return self._subscribers.get((owner_id, run_id), 0)
|
||||
|
||||
def prune(self, owner_id: str, run_id: str) -> bool:
|
||||
"""Drop a terminal entry with zero live SSE subscribers (P-6).
|
||||
|
||||
Terminal entries are otherwise a slow multi-user leak. Events
|
||||
for a pruned run 404 afterwards — documented acceptable; the
|
||||
trace persists on disk.
|
||||
"""
|
||||
entry = self._actors.get((owner_id, run_id))
|
||||
if entry is None:
|
||||
return False
|
||||
actor = entry[0]
|
||||
if actor.state.state not in TERMINAL_STATES:
|
||||
return False
|
||||
if self.subscriber_count(owner_id, run_id) > 0:
|
||||
return False
|
||||
self._actors.pop((owner_id, run_id), None)
|
||||
self._buses.pop((owner_id, run_id), None)
|
||||
self._subscribers.pop((owner_id, run_id), None)
|
||||
return True
|
||||
|
||||
|
||||
def create_app(
|
||||
@@ -138,16 +219,23 @@ def create_app(
|
||||
storage_dir: Path | None = None,
|
||||
model_adapter: Any = None,
|
||||
search_adapter: Any = None,
|
||||
auth_enabled: bool = True,
|
||||
cookie_secure: bool = False,
|
||||
data_dir: Path | None = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="ResearchX API", version="0.2.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
"""Build the ResearchX API app.
|
||||
|
||||
storage = LocalStorageAdapter(base_dir=storage_dir or Path.cwd() / ".researchx")
|
||||
``auth_enabled=False`` is a dev/test harness parameter ONLY — never
|
||||
an env var readable in production (C3-16). It resolves a synthetic
|
||||
dev admin for every request so zero-key suites keep working.
|
||||
``data_dir`` overrides the auth-store location (defaults to
|
||||
``data_root()``); ``storage_dir`` remains the run-artifact base.
|
||||
"""
|
||||
app = FastAPI(title="ResearchX API", version="0.2.0")
|
||||
|
||||
# D-107: run artifacts live under the data root (storage_dir
|
||||
# overrides for tests); auth stores always share the same root.
|
||||
storage_base = storage_dir if storage_dir is not None else data_root()
|
||||
runs = RunRegistry()
|
||||
model = model_adapter or FakeModelAdapter(
|
||||
canned=[
|
||||
@@ -157,8 +245,20 @@ def create_app(
|
||||
)
|
||||
search = search_adapter or FakeSearchAdapter(CORPUS)
|
||||
|
||||
def _summary(run_id: str) -> RunSummary:
|
||||
actor = runs.actor(run_id)
|
||||
auth_root = data_dir if data_dir is not None else data_root()
|
||||
users = UserStore(auth_root / "users.json")
|
||||
sessions = SessionStore(auth_root / "sessions.json")
|
||||
|
||||
guard: Guard = build_guard(users, sessions) if auth_enabled else build_dev_guard()
|
||||
admin_guard = build_admin_guard(guard)
|
||||
|
||||
def _owner_storage(user: UserPublic) -> LocalStorageAdapter:
|
||||
"""Owner-scoped storage, constructed per request from the auth
|
||||
dependency (REQ-71): runs/<user_id>/<run_id>/."""
|
||||
return LocalStorageAdapter(base_dir=storage_base, owner_id=user.user_id)
|
||||
|
||||
def _summary(user: UserPublic, run_id: str) -> RunSummary:
|
||||
actor = runs.actor(user.user_id, run_id)
|
||||
state = actor.state
|
||||
return RunSummary(
|
||||
id=run_id,
|
||||
@@ -169,21 +269,41 @@ def create_app(
|
||||
started_at=state.created_at.isoformat(),
|
||||
)
|
||||
|
||||
@app.get("/api/config")
|
||||
async def config_status() -> ConfigStatus:
|
||||
# ------------------------------------------------------------------
|
||||
# PUBLIC surface: /healthz outside /api (D-103 — plain GET for the
|
||||
# TLS-front proxy, never auth-wrapped) + /api/auth/* (no
|
||||
# require_user; the auth router enforces the CSRF header on its
|
||||
# own POSTs).
|
||||
# ------------------------------------------------------------------
|
||||
public = build_auth_router(users, sessions, cookie_secure=cookie_secure)
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PROTECTED router: every /api business route carries the guard.
|
||||
# Admin user management additionally carries the admin guard.
|
||||
# ------------------------------------------------------------------
|
||||
protected = APIRouter(dependencies=[Depends(guard)])
|
||||
|
||||
protected.include_router(build_admin_router(users, sessions, admin_guard))
|
||||
|
||||
@protected.get("/api/config")
|
||||
async def config_status(user: Annotated[UserPublic, Depends(guard)]) -> ConfigStatus:
|
||||
latest_probe = None
|
||||
for run_id in list(runs._actors)[-1:]:
|
||||
actor_probe = runs.actor(run_id).state.probe
|
||||
latest_probe = actor_probe
|
||||
for owner_and_run in list(runs._actors)[-1:]:
|
||||
if owner_and_run[0] == user.user_id:
|
||||
latest_probe = runs.actor(*owner_and_run).state.probe
|
||||
return ConfigStatus(
|
||||
model_adapter=type(model).__name__,
|
||||
search_adapter=type(search).__name__,
|
||||
storage_dir=str(storage.run_dir("00000000").parent),
|
||||
storage_dir=str(storage_base),
|
||||
probe=latest_probe,
|
||||
cost_counters=runs.cost_counters,
|
||||
cost_counters=runs.completed_snapshot(user.user_id),
|
||||
)
|
||||
|
||||
@app.get("/api/methodologies")
|
||||
@protected.get("/api/methodologies")
|
||||
async def methodologies() -> list[MethodologyCard]:
|
||||
cards: list[MethodologyCard] = []
|
||||
for methodology in registry.list():
|
||||
@@ -202,10 +322,11 @@ def create_app(
|
||||
)
|
||||
return cards
|
||||
|
||||
@app.post("/api/runs", status_code=201)
|
||||
async def create_run(body: CreateRunBody) -> RunSummary:
|
||||
@protected.post("/api/runs", status_code=201)
|
||||
async def create_run(body: CreateRunBody, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if body.methodology not in registry:
|
||||
raise HTTPException(status_code=404, detail="unknown methodology")
|
||||
storage = _owner_storage(user)
|
||||
try:
|
||||
run_id, config = make_run_config(
|
||||
question=body.question,
|
||||
@@ -213,6 +334,7 @@ def create_app(
|
||||
methodology_slug=body.methodology,
|
||||
overrides=body.dial_overrides,
|
||||
registry=registry,
|
||||
owner_id=user.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
@@ -227,37 +349,42 @@ def create_app(
|
||||
registry=registry,
|
||||
event_bus=bus,
|
||||
)
|
||||
probe_data: dict[str, Any] | None = None
|
||||
# Stamp the owner on the persisted state (REQ-71). The actor
|
||||
# itself is untouched — engine contracts unchanged.
|
||||
actor.state.owner_id = user.user_id
|
||||
if supports_probe(model):
|
||||
try:
|
||||
probe = await probe_model_adapter(model)
|
||||
probe_data = probe.model_dump(mode="json")
|
||||
await actor.attach_probe(probe_data)
|
||||
await actor.attach_probe(probe.model_dump(mode="json"))
|
||||
except AdapterError:
|
||||
probe_data = None
|
||||
pass
|
||||
|
||||
task = asyncio.create_task(actor.run())
|
||||
runs.add(run_id, actor, task, bus)
|
||||
runs.add(user.user_id, run_id, actor, task, bus)
|
||||
|
||||
async def _watch() -> None:
|
||||
state = await task
|
||||
if state.state == RunStateName.DONE:
|
||||
runs.note_completed()
|
||||
runs.note_completed(user.user_id)
|
||||
|
||||
asyncio.create_task(_watch())
|
||||
return _summary(run_id)
|
||||
return _summary(user, run_id)
|
||||
|
||||
@app.get("/api/runs/{run_id}")
|
||||
async def get_run(run_id: str) -> RunSummary:
|
||||
if not runs.has(run_id):
|
||||
@protected.get("/api/runs/{run_id}")
|
||||
async def get_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
return _summary(run_id)
|
||||
return _summary(user, run_id)
|
||||
|
||||
@app.post("/api/runs/{run_id}/approve")
|
||||
async def approve_run(run_id: str, body: ApproveBody | None = None) -> RunSummary:
|
||||
if not runs.has(run_id):
|
||||
@protected.post("/api/runs/{run_id}/approve")
|
||||
async def approve_run(
|
||||
run_id: str,
|
||||
user: Annotated[UserPublic, Depends(guard)],
|
||||
body: ApproveBody | None = None,
|
||||
) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
actor = runs.actor(run_id)
|
||||
actor = runs.actor(user.user_id, run_id)
|
||||
edited_plan: Plan | None = None
|
||||
if body and body.plan:
|
||||
try:
|
||||
@@ -265,38 +392,40 @@ def create_app(
|
||||
except ValidationError:
|
||||
raise HTTPException(status_code=422, detail="invalid plan")
|
||||
await actor.approve(edited_plan)
|
||||
return _summary(run_id)
|
||||
return _summary(user, run_id)
|
||||
|
||||
@app.post("/api/runs/{run_id}/pause")
|
||||
async def pause_run(run_id: str) -> RunSummary:
|
||||
if not runs.has(run_id):
|
||||
@protected.post("/api/runs/{run_id}/pause")
|
||||
async def pause_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
runs.actor(run_id).pause()
|
||||
return _summary(run_id)
|
||||
runs.actor(user.user_id, run_id).pause()
|
||||
return _summary(user, run_id)
|
||||
|
||||
@app.post("/api/runs/{run_id}/resume")
|
||||
async def resume_run(run_id: str) -> RunSummary:
|
||||
if not runs.has(run_id):
|
||||
@protected.post("/api/runs/{run_id}/resume")
|
||||
async def resume_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
runs.actor(run_id).resume()
|
||||
return _summary(run_id)
|
||||
runs.actor(user.user_id, run_id).resume()
|
||||
return _summary(user, run_id)
|
||||
|
||||
@app.post("/api/runs/{run_id}/cancel")
|
||||
async def cancel_run(run_id: str) -> RunSummary:
|
||||
if not runs.has(run_id):
|
||||
@protected.post("/api/runs/{run_id}/cancel")
|
||||
async def cancel_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
actor = runs.actor(run_id)
|
||||
actor = runs.actor(user.user_id, run_id)
|
||||
actor.cancel()
|
||||
actor.resume()
|
||||
return _summary(run_id)
|
||||
return _summary(user, run_id)
|
||||
|
||||
@app.get("/api/runs/{run_id}/events")
|
||||
async def run_events(run_id: str) -> EventSourceResponse:
|
||||
if not runs.has(run_id):
|
||||
@protected.get("/api/runs/{run_id}/events")
|
||||
async def run_events(
|
||||
run_id: str, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> EventSourceResponse:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
bus = runs.bus(run_id)
|
||||
actor = runs.actor(run_id)
|
||||
terminal = {RunStateName.DONE, RunStateName.CANCELLED, RunStateName.FAILED}
|
||||
bus = runs.bus(user.user_id, run_id)
|
||||
actor = runs.actor(user.user_id, run_id)
|
||||
terminal = TERMINAL_STATES
|
||||
|
||||
async def event_stream() -> AsyncIterator[dict[str, str]]:
|
||||
queue: asyncio.Queue[RunEvent] = asyncio.Queue()
|
||||
@@ -304,6 +433,7 @@ def create_app(
|
||||
async def forward(event: RunEvent) -> None:
|
||||
await queue.put(event)
|
||||
|
||||
runs.subscribe_events(user.user_id, run_id) # SSE refcount (P-6)
|
||||
bus.subscribe(forward)
|
||||
try:
|
||||
replay = bus.replay()
|
||||
@@ -325,27 +455,31 @@ def create_app(
|
||||
return
|
||||
finally:
|
||||
bus.unsubscribe(forward)
|
||||
runs.unsubscribe_events(user.user_id, run_id)
|
||||
# Registry pruning (P-6): a terminal run whose last SSE
|
||||
# subscriber just left is dropped from the registry.
|
||||
# Events for a pruned run 404 afterwards — documented
|
||||
# acceptable; the trace persists on disk.
|
||||
runs.prune(user.user_id, run_id)
|
||||
|
||||
return EventSourceResponse(event_stream())
|
||||
|
||||
return EventSourceResponse(event_stream())
|
||||
|
||||
@app.get("/api/runs/{run_id}/report")
|
||||
async def run_report(run_id: str) -> dict[str, Any]:
|
||||
if not runs.has(run_id):
|
||||
@protected.get("/api/runs/{run_id}/report")
|
||||
async def run_report(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
try:
|
||||
content = await storage.read_run_artifact(run_id, "report.md")
|
||||
content = await _owner_storage(user).read_run_artifact(run_id, "report.md")
|
||||
except AdapterError:
|
||||
raise HTTPException(status_code=404, detail="report not ready")
|
||||
return {"run_id": run_id, "markdown": content}
|
||||
|
||||
@app.get("/api/runs/{run_id}/ledger")
|
||||
async def run_ledger(run_id: str) -> dict[str, Any]:
|
||||
if not runs.has(run_id):
|
||||
@protected.get("/api/runs/{run_id}/ledger")
|
||||
async def run_ledger(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
try:
|
||||
content = await storage.read_run_artifact(run_id, "ledger.json")
|
||||
content = await _owner_storage(user).read_run_artifact(run_id, "ledger.json")
|
||||
except AdapterError:
|
||||
raise HTTPException(status_code=404, detail="ledger not ready")
|
||||
import json
|
||||
@@ -353,7 +487,33 @@ def create_app(
|
||||
data: dict[str, Any] = json.loads(content)
|
||||
return data
|
||||
|
||||
app.include_router(public)
|
||||
app.include_router(protected)
|
||||
|
||||
# Startup sweep (D-115, G-101): every non-terminal state.json under
|
||||
# the storage base is rewritten as CANCELLED — in-flight actors died
|
||||
# with the previous process; rehydration is out of v0.3 scope. Runs
|
||||
# synchronously at app construction so the very first request
|
||||
# already sees a consistent disk.
|
||||
from researchx.api.sweep import sweep_in_flight_runs
|
||||
|
||||
sweep_in_flight_runs(storage_base)
|
||||
|
||||
# CORS is added AFTER the routers so it is the OUTERMOST middleware
|
||||
# (starlette builds the stack in add-order: last added = outermost)
|
||||
# — preflight OPTIONS is answered without any auth 401 (P-4/C3-04).
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
# Dev-only module app (README's `uvicorn researchx.api.app:app`).
|
||||
# Pitfall P-12/§8 item 3: this serves FAKES with default storage —
|
||||
# the production entrypoint (researchx.api.main, Phase 2) must never
|
||||
# import this global. Single worker only (in-memory stores by design).
|
||||
app = create_app()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Startup sweep (D-115, G-101).
|
||||
|
||||
On app construction, every non-terminal ``state.json`` under the
|
||||
storage base is rewritten as CANCELLED — in-flight actors died with the
|
||||
previous process; rehydration is out of v0.3 scope. CANCELLED is legal
|
||||
from every non-terminal state (states.py — including PLANNING per
|
||||
G-101, pinned by test). The registry-side pruning lives in app.py
|
||||
(RunRegistry.prune, P-6).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.engine.run_state import RunState
|
||||
from researchx.engine.states import TERMINAL_STATES, RunStateName
|
||||
|
||||
|
||||
def _atomic_write(path: Path, content: str) -> None:
|
||||
import os
|
||||
import secrets
|
||||
|
||||
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
tmp_path.replace(path)
|
||||
|
||||
|
||||
def sweep_in_flight_runs(base_dir: Path) -> list[tuple[str, str]]:
|
||||
"""Rewrite every non-terminal state.json under base_dir as CANCELLED.
|
||||
|
||||
Scans ``base_dir/runs/<owner_id>/<run_id>/state.json`` (the
|
||||
partitioned layout, D-106). Returns the swept (owner_id, run_id)
|
||||
pairs. Corrupt state files are skipped defensively — a sweep must
|
||||
never prevent startup.
|
||||
"""
|
||||
swept: list[tuple[str, str]] = []
|
||||
runs_dir = base_dir / "runs"
|
||||
if not runs_dir.is_dir():
|
||||
return swept
|
||||
for owner_dir in sorted(runs_dir.iterdir()):
|
||||
if not owner_dir.is_dir() or owner_dir.name.startswith("."):
|
||||
continue
|
||||
for run_dir in sorted(owner_dir.iterdir()):
|
||||
if not run_dir.is_dir() or run_dir.name.startswith("."):
|
||||
continue
|
||||
state_path = run_dir / "state.json"
|
||||
if not state_path.is_file():
|
||||
continue
|
||||
try:
|
||||
state = RunState.model_validate_json(state_path.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError):
|
||||
# Corrupt/unreadable state files are not swept, never
|
||||
# fatal — a sweep must never prevent startup.
|
||||
continue
|
||||
if state.state in TERMINAL_STATES:
|
||||
continue
|
||||
state.state = RunStateName.CANCELLED # legal from every non-terminal state (G-101)
|
||||
_atomic_write(state_path, state.model_dump_json())
|
||||
swept.append((owner_dir.name, run_dir.name))
|
||||
return swept
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Auth package: user accounts, sessions, guard, and routes (REQ-69/70/71)."""
|
||||
|
||||
from researchx.auth.models import (
|
||||
AdminCreateUserBody,
|
||||
LoginBody,
|
||||
RegisterBody,
|
||||
UserPublic,
|
||||
to_public,
|
||||
)
|
||||
from researchx.auth.sessions import Session, SessionStore
|
||||
from researchx.auth.users import User, UserError, UserStore
|
||||
|
||||
__all__ = [
|
||||
"AdminCreateUserBody",
|
||||
"LoginBody",
|
||||
"RegisterBody",
|
||||
"Session",
|
||||
"SessionStore",
|
||||
"User",
|
||||
"UserError",
|
||||
"UserPublic",
|
||||
"UserStore",
|
||||
"to_public",
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Admin user-management routes (T-1.5, REQ-70).
|
||||
|
||||
Mounted on the PROTECTED router (session + CSRF already enforced by
|
||||
``require_user``); every route additionally requires the admin role.
|
||||
Responses expose ``UserPublic`` only — never a password hash. Account
|
||||
creation enforces the same >=12 password policy as registration;
|
||||
deactivation sets ``active=False`` and immediately revokes every
|
||||
session of the target user (data is retained).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from researchx.auth.guard import Guard
|
||||
from researchx.auth.models import AdminCreateUserBody, UserPublic, to_public
|
||||
from researchx.auth.sessions import SessionStore
|
||||
from researchx.auth.users import UserError, UserStore
|
||||
|
||||
|
||||
def build_admin_router(
|
||||
users: UserStore,
|
||||
sessions: SessionStore,
|
||||
admin_guard: Guard,
|
||||
) -> APIRouter:
|
||||
"""Build the admin user surface (mounted at /api/admin, admin-only)."""
|
||||
router = APIRouter(
|
||||
prefix="/api/admin",
|
||||
tags=["admin"],
|
||||
dependencies=[Depends(admin_guard)],
|
||||
)
|
||||
|
||||
@router.get("/users", response_model=list[UserPublic])
|
||||
async def list_users() -> list[UserPublic]:
|
||||
return [to_public(u) for u in users.list()]
|
||||
|
||||
@router.post("/users", status_code=201, response_model=UserPublic)
|
||||
async def create_user(body: AdminCreateUserBody) -> UserPublic:
|
||||
try:
|
||||
user = await users.create_user(
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
display_name=body.display_name or body.email.split("@")[0],
|
||||
role=body.role,
|
||||
)
|
||||
except UserError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return to_public(user)
|
||||
|
||||
@router.post("/users/{user_id}/deactivate", response_model=UserPublic)
|
||||
async def deactivate_user(user_id: str) -> UserPublic:
|
||||
updated = users.deactivate(user_id)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="user not found")
|
||||
sessions.revoke_all_for(user_id) # deactivation kills sessions immediately
|
||||
return to_public(updated)
|
||||
|
||||
return router
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Session store — server-side, opaque tokens, durable JSON mirror (D-101).
|
||||
|
||||
Tokens are ``secrets.token_urlsafe(32)``; only their sha256 hash is
|
||||
stored (in memory and in ``<data_root>/sessions.json``). Expiry is
|
||||
14-day sliding: every authenticated request calls ``touch()`` which
|
||||
pushes ``expires_at`` forward, persisting at most once per 60s.
|
||||
Revocation is immediate (logout deletes the presented token;
|
||||
deactivation deletes every token of the user).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
SESSION_TTL_SECONDS = 14 * 24 * 3600
|
||||
_PERSIST_THROTTLE_SECONDS = 60.0
|
||||
|
||||
|
||||
class Session(BaseModel):
|
||||
token_hash: str
|
||||
user_id: str
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
last_seen: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
expires_at: datetime
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""In-memory map keyed by token_hash + durable sessions.json mirror."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._path = path
|
||||
self._sessions: dict[str, Session] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._last_persist = 0.0
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------ persistence
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self._path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
for record in raw.get("sessions", []):
|
||||
try:
|
||||
session = Session.model_validate(record)
|
||||
except (ValidationError, KeyError, TypeError):
|
||||
continue # defensive: skip corrupt records on load
|
||||
self._sessions[session.token_hash] = session
|
||||
|
||||
def _persist_locked(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"sessions": [s.model_dump(mode="json") for s in self._sessions.values()]
|
||||
}
|
||||
tmp_path = self._path.with_name(f"{self._path.name}.{secrets.token_hex(4)}.tmp")
|
||||
content = json.dumps(payload, indent=2)
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, self._path)
|
||||
self._last_persist = time.monotonic()
|
||||
|
||||
def _persist(self) -> None:
|
||||
with self._lock:
|
||||
self._persist_locked()
|
||||
|
||||
# ------------------------------------------------------------- queries
|
||||
|
||||
def get(self, token: str) -> Session | None:
|
||||
"""Resolve a presented token; expired or unknown tokens yield None."""
|
||||
token_hash = _hash_token(token)
|
||||
session = self._sessions.get(token_hash)
|
||||
if session is None:
|
||||
return None
|
||||
if session.expires_at <= datetime.now(UTC):
|
||||
with self._lock:
|
||||
expired = self._sessions.pop(token_hash, None)
|
||||
if expired is not None:
|
||||
self._persist_locked()
|
||||
return None
|
||||
return session
|
||||
|
||||
# ------------------------------------------------------------- mutation
|
||||
|
||||
def create(self, token: str, user_id: str) -> Session:
|
||||
"""Store a new session for *token* (only its hash is kept)."""
|
||||
now = datetime.now(UTC)
|
||||
session = Session(
|
||||
token_hash=_hash_token(token),
|
||||
user_id=user_id,
|
||||
created_at=now,
|
||||
last_seen=now,
|
||||
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
|
||||
)
|
||||
with self._lock:
|
||||
self._sessions[session.token_hash] = session
|
||||
self._persist_locked()
|
||||
return session
|
||||
|
||||
def touch(self, session: Session) -> None:
|
||||
"""Sliding expiry: push expires_at forward, throttled persist."""
|
||||
now = datetime.now(UTC)
|
||||
renewed = Session(
|
||||
token_hash=session.token_hash,
|
||||
user_id=session.user_id,
|
||||
created_at=session.created_at,
|
||||
last_seen=now,
|
||||
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
|
||||
)
|
||||
with self._lock:
|
||||
current = self._sessions.get(session.token_hash)
|
||||
if current is None or current != session:
|
||||
return # stale or raced touch — nothing to extend
|
||||
self._sessions[renewed.token_hash] = renewed
|
||||
if time.monotonic() - self._last_persist > _PERSIST_THROTTLE_SECONDS:
|
||||
self._persist_locked()
|
||||
|
||||
def revoke(self, token: str) -> None:
|
||||
"""Immediately delete the session for *token* (logout)."""
|
||||
token_hash = _hash_token(token)
|
||||
with self._lock:
|
||||
if self._sessions.pop(token_hash, None) is not None:
|
||||
self._persist_locked()
|
||||
|
||||
def revoke_all_for(self, user_id: str) -> None:
|
||||
"""Immediately delete every session of *user_id* (deactivation)."""
|
||||
with self._lock:
|
||||
doomed = [h for h, s in self._sessions.items() if s.user_id == user_id]
|
||||
for token_hash in doomed:
|
||||
del self._sessions[token_hash]
|
||||
if doomed:
|
||||
self._persist_locked()
|
||||
|
||||
def generate_token(self) -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
# ----------------------------------------------------------- asyncio shim
|
||||
|
||||
async def arevoke(self, token: str) -> None:
|
||||
await asyncio.to_thread(self.revoke, token)
|
||||
|
||||
async def arevoke_all_for(self, user_id: str) -> None:
|
||||
await asyncio.to_thread(self.revoke_all_for, user_id)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""User store — durable JSON-backed account registry (REQ-69, D-102/D-105).
|
||||
|
||||
Persisted to ``<data_root>/users.json`` with the atomic tmp+``os.replace``
|
||||
idiom (D-017). Password hashing is argon2id via argon2-cffi
|
||||
``PasswordHasher()`` defaults; every hash/verify runs through
|
||||
``asyncio.to_thread`` because argon2 verify at default parameters costs
|
||||
~50-100ms of CPU and must leave the event loop alone.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from researchx.config.ids import generate_slug
|
||||
|
||||
MIN_PASSWORD_LENGTH = 12
|
||||
|
||||
|
||||
class UserError(ValueError):
|
||||
"""Raised for user-store policy violations (duplicate email, weak password)."""
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
user_id: str = Field(pattern=r"^[a-z0-9]{8}$")
|
||||
email: str
|
||||
password_hash: str
|
||||
display_name: str
|
||||
role: str = "user"
|
||||
active: bool = True
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class UserStore:
|
||||
"""In-memory map keyed by user_id + durable users.json mirror."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._path = path
|
||||
self._by_id: dict[str, User] = {}
|
||||
self._by_email: dict[str, User] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._hasher = PasswordHasher()
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------ persistence
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self._path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
# FAIL CLOSED on a corrupt user store. Swallowing it would
|
||||
# silently report an empty store, reopening registration on
|
||||
# top of existing accounts — the next persist would then
|
||||
# overwrite them (auth-reset-by-corruption). The file is
|
||||
# renamed aside so an operator can recover it; startup is
|
||||
# refused rather than silently degrading.
|
||||
salvage = self._path.with_name(f"{self._path.name}.corrupt.{secrets.token_hex(4)}")
|
||||
os.replace(self._path, salvage)
|
||||
raise RuntimeError(
|
||||
f"corrupt user store {self._path}; moved aside to {salvage}"
|
||||
) from exc
|
||||
for record in raw.get("users", []):
|
||||
try:
|
||||
user = User.model_validate(record)
|
||||
except (ValidationError, KeyError, TypeError):
|
||||
continue # defensive: skip corrupt records on load
|
||||
self._by_id[user.user_id] = user
|
||||
self._by_email[user.email] = user
|
||||
|
||||
def _persist_locked(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"users": [u.model_dump(mode="json") for u in self._by_id.values()]}
|
||||
tmp_path = self._path.with_name(f"{self._path.name}.{secrets.token_hex(4)}.tmp")
|
||||
content = json.dumps(payload, indent=2)
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, self._path)
|
||||
|
||||
def _persist(self) -> None:
|
||||
with self._lock:
|
||||
self._persist_locked()
|
||||
|
||||
# ------------------------------------------------------------- hashing
|
||||
|
||||
async def hash_password(self, password: str) -> str:
|
||||
return await asyncio.to_thread(self._hasher.hash, password)
|
||||
|
||||
async def verify_password(self, user: User, password: str) -> bool:
|
||||
try:
|
||||
await asyncio.to_thread(self._hasher.verify, user.password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def needs_rehash(self, user: User) -> bool:
|
||||
return await asyncio.to_thread(self._hasher.check_needs_rehash, user.password_hash)
|
||||
|
||||
# ------------------------------------------------------------- queries
|
||||
|
||||
def empty(self) -> bool:
|
||||
return not self._by_id
|
||||
|
||||
def get(self, user_id: str) -> User | None:
|
||||
return self._by_id.get(user_id)
|
||||
|
||||
def get_by_email(self, email: str) -> User | None:
|
||||
return self._by_email.get(email.casefold())
|
||||
|
||||
def list(self) -> list[User]:
|
||||
return sorted(self._by_id.values(), key=lambda u: (u.created_at, u.user_id))
|
||||
|
||||
# ------------------------------------------------------------- mutation
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: str,
|
||||
role: str = "user",
|
||||
) -> User:
|
||||
normalized = email.casefold()
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise UserError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
|
||||
if self._by_email.get(normalized) is not None:
|
||||
raise UserError("email already registered")
|
||||
password_hash = await self.hash_password(password)
|
||||
user = User(
|
||||
user_id=generate_slug(),
|
||||
email=normalized,
|
||||
password_hash=password_hash,
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
)
|
||||
with self._lock:
|
||||
if self._by_email.get(normalized) is not None:
|
||||
raise UserError("email already registered")
|
||||
self._by_id[user.user_id] = user
|
||||
self._by_email[user.email] = user
|
||||
self._persist_locked()
|
||||
return user
|
||||
|
||||
async def rehash_password(self, user: User, password: str) -> None:
|
||||
"""Rehash a user's password in place (login-time rehash on parameter drift)."""
|
||||
password_hash = await self.hash_password(password)
|
||||
with self._lock:
|
||||
updated = user.model_copy(update={"password_hash": password_hash})
|
||||
self._by_id[updated.user_id] = updated
|
||||
self._by_email[updated.email] = updated
|
||||
self._persist_locked()
|
||||
|
||||
async def create_first_admin(self, *, email: str, password: str, display_name: str) -> User:
|
||||
"""Atomically create the first user, who is admin by construction.
|
||||
|
||||
The emptiness check + insert both happen under the store lock;
|
||||
the only await (the argon2 hash) precedes the check. Combined
|
||||
with the single uvicorn worker this makes registration open
|
||||
exactly once (D-104).
|
||||
"""
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise UserError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
|
||||
normalized = email.casefold()
|
||||
password_hash = await self.hash_password(password) # the one allowed await
|
||||
with self._lock:
|
||||
if self._by_id:
|
||||
raise UserError("registration closed")
|
||||
if self._by_email.get(normalized) is not None:
|
||||
raise UserError("email already registered")
|
||||
user = User(
|
||||
user_id=generate_slug(),
|
||||
email=normalized,
|
||||
password_hash=password_hash,
|
||||
display_name=display_name,
|
||||
role="admin",
|
||||
)
|
||||
self._by_id[user.user_id] = user
|
||||
self._by_email[user.email] = user
|
||||
self._persist_locked()
|
||||
return user
|
||||
|
||||
def deactivate(self, user_id: str) -> User | None:
|
||||
with self._lock:
|
||||
user = self._by_id.get(user_id)
|
||||
if user is None:
|
||||
return None
|
||||
updated = user.model_copy(update={"active": False})
|
||||
self._by_id[user_id] = updated
|
||||
self._by_email[updated.email] = updated
|
||||
self._persist_locked()
|
||||
return updated
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -13,12 +13,14 @@ from researchx.methodologies import registry
|
||||
async def main() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
owner_id = "demo0001" # 8-char owner slug (P-9: demo passes the owner)
|
||||
run_id, config = make_run_config(
|
||||
question="How do verification pipelines keep citations honest?",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
plan = (
|
||||
'{"sub_questions": ["verification passes", "quote anchoring"], '
|
||||
@@ -36,7 +38,7 @@ async def main() -> None:
|
||||
]
|
||||
)
|
||||
search = FakeSearchAdapter(CORPUS)
|
||||
storage = LocalStorageAdapter(base_dir=base)
|
||||
storage = LocalStorageAdapter(base_dir=base, owner_id=owner_id)
|
||||
bus = EventBus()
|
||||
actor = RunActor(
|
||||
run_config=config, model=model, search=search, storage=storage, registry=registry,
|
||||
|
||||
@@ -18,6 +18,9 @@ class RunState(BaseModel):
|
||||
context: str = ""
|
||||
methodology_slug: str
|
||||
state: RunStateName = RunStateName.PLANNING
|
||||
# Owner of the run (REQ-71). None on pre-v0.3 state files — old
|
||||
# artifacts must deserialize unchanged (D-106: no migration).
|
||||
owner_id: str | None = None
|
||||
plan: Plan | None = None
|
||||
depth_cursor: int = 0
|
||||
depth_target: int = 1
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
"""Auth route suite (T-1.9 backend half, REQ-69/70 — RESEARCH §11 list).
|
||||
|
||||
Full adversarial surface of the auth layer, run with
|
||||
``auth_enabled=True``: first-user-admin, registration closing, uniform
|
||||
login failures, session/logout semantics, CSRF enforcement, admin
|
||||
create/deactivate (deactivation kills sessions), and the
|
||||
no-password-hash-anywhere guarantee.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.api.app import create_app
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
_ADMIN_EMAIL = "admin@example.org"
|
||||
_ADMIN_PASSWORD = "correct-horse-battery"
|
||||
_SECOND_EMAIL = "second@example.org"
|
||||
_SECOND_PASSWORD = "another-long-password"
|
||||
|
||||
_HEADERS = {CSRF_HEADER: "1"}
|
||||
|
||||
|
||||
def _app(tmp_path: Path):
|
||||
return create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
async def _client(tmp_path: Path) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(tmp_path)),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
async def _register_first_admin(client: httpx.AsyncClient) -> httpx.Response:
|
||||
return await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
async def _login(client: httpx.AsyncClient, email: str, password: str) -> httpx.Response:
|
||||
return await client.post(
|
||||
"/api/auth/login", json={"email": email, "password": password}, headers=_HEADERS
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ register
|
||||
|
||||
|
||||
async def test_healthz_outside_api_is_public(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
async def test_first_register_creates_admin(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await _register_first_admin(client)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["role"] == "admin"
|
||||
assert body["email"] == _ADMIN_EMAIL
|
||||
assert body["active"] is True
|
||||
assert len(body["user_id"]) == 8
|
||||
assert "password_hash" not in body
|
||||
assert "password" not in body
|
||||
|
||||
|
||||
async def test_second_register_is_403(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
response = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "registration closed"
|
||||
|
||||
|
||||
async def test_register_rejects_short_password(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": "short"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert response.status_code == 422 # pydantic contract rejects < 12
|
||||
|
||||
|
||||
async def test_register_requires_csrf_header(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "missing request header"
|
||||
|
||||
|
||||
async def test_concurrent_register_yields_exactly_one_admin(tmp_path: Path) -> None:
|
||||
"""D-104 adversarial pin: a simultaneous register storm on a fresh
|
||||
store must produce exactly one 201/admin — the emptiness check +
|
||||
insert are atomic under the store lock (create_first_admin), so a
|
||||
raced duplicate cannot both win. Regression guard for the
|
||||
create_user-based route that let N concurrent registers through."""
|
||||
racers = [(f"racer{i}@example.org", f"racing-password-{i:02d}") for i in range(8)]
|
||||
async with await _client(tmp_path) as client:
|
||||
responses = await asyncio.gather(
|
||||
*(
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": email, "password": password},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
for email, password in racers
|
||||
)
|
||||
)
|
||||
winners = [(r, racer) for r, racer in zip(responses, racers, strict=True) if r.status_code == 201]
|
||||
losers = [r for r in responses if r.status_code == 403]
|
||||
assert len(winners) == 1, "exactly one register may win the race"
|
||||
assert len(losers) == 7
|
||||
assert winners[0][0].json()["role"] == "admin"
|
||||
|
||||
# The winner can log in; every losing racer has no account.
|
||||
_, (winner_email, winner_password) = winners[0]
|
||||
login = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": winner_email, "password": winner_password},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert login.status_code == 200
|
||||
for email, password in racers:
|
||||
if email == winner_email:
|
||||
continue
|
||||
denied = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert denied.status_code == 401, "losing racer must have no account"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- login
|
||||
|
||||
|
||||
async def test_login_sets_httponly_cookie(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
response = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["role"] == "admin"
|
||||
set_cookie = response.headers["set-cookie"]
|
||||
assert "researchx_session=" in set_cookie
|
||||
assert "httponly" in set_cookie.lower()
|
||||
assert "samesite=lax" in set_cookie.lower()
|
||||
assert "path=/" in set_cookie.lower()
|
||||
assert "domain=" not in set_cookie.lower().replace("samesite=lax", "")
|
||||
assert "secure" not in set_cookie.lower() # cookie_secure=False in tests
|
||||
|
||||
|
||||
async def test_login_wrong_password_uniform_401(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
for email, password in [
|
||||
(_ADMIN_EMAIL, "wrong-password-here"),
|
||||
("ghost@example.org", _ADMIN_PASSWORD),
|
||||
]:
|
||||
response = await _login(client, email, password)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "invalid email or password"
|
||||
|
||||
|
||||
async def test_login_token_rotation(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
first = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
first_cookie = first.headers["set-cookie"].split(";")[0]
|
||||
second = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
second_cookie = second.headers["set-cookie"].split(";")[0]
|
||||
assert first_cookie != second_cookie # fresh token every login
|
||||
|
||||
|
||||
# ----------------------------------------------------------- guard + CSRF
|
||||
|
||||
|
||||
async def test_unauthenticated_401_on_protected_routes(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
for method, path in [
|
||||
("GET", "/api/methodologies"),
|
||||
("GET", "/api/config"),
|
||||
("GET", "/api/runs/abcd2345"),
|
||||
("GET", "/api/runs/abcd2345/events"),
|
||||
("GET", "/api/runs/abcd2345/report"),
|
||||
("GET", "/api/admin/users"),
|
||||
]:
|
||||
response = await client.request(method, path)
|
||||
assert response.status_code == 401, f"{method} {path} leaked without auth"
|
||||
assert response.json()["detail"] == "authentication required"
|
||||
|
||||
|
||||
async def test_csrf_header_required_on_non_get(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
# Session cookie attached, but no CSRF header on a POST.
|
||||
response = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "missing request header"
|
||||
|
||||
|
||||
async def test_gets_exempt_from_csrf_header(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
assert (await client.get("/api/methodologies")).status_code == 200
|
||||
assert (await client.get("/api/auth/me")).status_code == 200
|
||||
|
||||
|
||||
# ----------------------------------------------------------- me + logout
|
||||
|
||||
|
||||
async def test_me_returns_current_user(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.get("/api/auth/me")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["email"] == _ADMIN_EMAIL
|
||||
assert "password_hash" not in body
|
||||
|
||||
|
||||
async def test_logout_revokes_session(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
assert (await client.get("/api/methodologies")).status_code == 200
|
||||
response = await client.post("/api/auth/logout", headers=_HEADERS)
|
||||
assert response.status_code == 200
|
||||
assert (await client.get("/api/methodologies")).status_code == 401
|
||||
|
||||
|
||||
async def test_deactivated_user_401(tmp_path: Path) -> None:
|
||||
app = _app(tmp_path)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
second_id = created.json()["user_id"]
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert (await second.get("/api/methodologies")).status_code == 200
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
deactivated = await admin.post(
|
||||
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
# The deactivated user's session was revoked server-side.
|
||||
response = await second.get("/api/methodologies")
|
||||
assert response.status_code == 401
|
||||
|
||||
# A fresh login against the deactivated account also 401s —
|
||||
# uniformly, without revealing the reason (no enumeration).
|
||||
login = await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert login.status_code == 401
|
||||
assert login.json()["detail"] == "invalid email or password"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- admin
|
||||
|
||||
|
||||
async def test_admin_list_users_no_hashes(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.get("/api/admin/users")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body) == 1
|
||||
raw = json.dumps(body)
|
||||
assert "password_hash" not in raw
|
||||
assert _ADMIN_PASSWORD not in raw
|
||||
assert "argon2" not in raw
|
||||
|
||||
|
||||
async def test_admin_create_user_then_user_login(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
assert body["role"] == "user"
|
||||
assert body["active"] is True
|
||||
|
||||
login = await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
assert login.status_code == 200
|
||||
assert login.json()["role"] == "user"
|
||||
|
||||
|
||||
async def test_admin_create_rejects_short_password(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": "short"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_admin_deactivate_revokes_sessions(tmp_path: Path) -> None:
|
||||
app = _app(tmp_path)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
second_id = created.json()["user_id"]
|
||||
|
||||
# A separate client (separate cookie jar) for the second user.
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert (await second.get("/api/methodologies")).status_code == 200
|
||||
|
||||
# Deactivation from the admin session kills the second session.
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as admin:
|
||||
await _register_first_admin(admin) # store shared; already admin
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
deactivated = await admin.post(
|
||||
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
assert deactivated.json()["active"] is False
|
||||
|
||||
response = await second.get("/api/methodologies")
|
||||
assert response.status_code == 401 # session revoked by deactivation
|
||||
|
||||
|
||||
async def test_non_admin_cannot_reach_admin_routes(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
response = await client.get("/api/admin/users")
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "admin role required"
|
||||
|
||||
|
||||
async def test_admin_deactivate_unknown_user_404(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.post("/api/admin/users/zzzz9999/deactivate", headers=_HEADERS)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ------------------------------------------------------ authenticated runs
|
||||
|
||||
|
||||
async def test_cross_owner_access_404_on_every_run_endpoint(tmp_path: Path) -> None:
|
||||
"""Two-user isolation (REQ-71): zero visibility across every run
|
||||
endpoint — cross-owner access is indistinguishable from a missing
|
||||
run (404, not 403: no existence leak)."""
|
||||
app = _app(tmp_path)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/runs",
|
||||
json={"question": "admin's private question", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
login = await _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
assert login.status_code == 200
|
||||
del login # ids don't matter here; counters do
|
||||
|
||||
for method, path in [
|
||||
("GET", f"/api/runs/{run_id}"),
|
||||
("POST", f"/api/runs/{run_id}/approve"),
|
||||
("POST", f"/api/runs/{run_id}/pause"),
|
||||
("POST", f"/api/runs/{run_id}/resume"),
|
||||
("POST", f"/api/runs/{run_id}/cancel"),
|
||||
("GET", f"/api/runs/{run_id}/events"),
|
||||
("GET", f"/api/runs/{run_id}/report"),
|
||||
("GET", f"/api/runs/{run_id}/ledger"),
|
||||
]:
|
||||
response = await second.request(method, path, headers=_HEADERS)
|
||||
assert response.status_code == 404, f"{method} {path} leaked across owners"
|
||||
|
||||
# The owner still sees their run — the 404s were authorization, not
|
||||
# the run vanishing.
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await admin.get(f"/api/runs/{run_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_owner_id_recorded_in_persisted_state(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
login = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
user_id = login.json()["user_id"]
|
||||
created = await client.post(
|
||||
"/api/runs",
|
||||
json={"question": "q", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
run_id = created.json()["id"]
|
||||
|
||||
state_path = tmp_path / "runs" / user_id / run_id / "state.json"
|
||||
for _ in range(200): # the actor persists asynchronously
|
||||
if state_path.exists():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert state_path.exists(), "storage layout must be runs/<user_id>/<run_id>/"
|
||||
state_text = state_path.read_text()
|
||||
state = json.loads(state_text)
|
||||
assert state["owner_id"] == user_id
|
||||
|
||||
|
||||
async def test_cost_counters_owner_keyed(tmp_path: Path) -> None:
|
||||
app = _app(tmp_path)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/runs",
|
||||
json={"question": "q", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
config = await admin.get("/api/config")
|
||||
counters = config.json()["cost_counters"]
|
||||
assert counters["runs_created"] == 1 # the admin's own view, owner-keyed
|
||||
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
created = await second.post(
|
||||
"/api/runs",
|
||||
json={"question": "q2", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
config = await second.get("/api/config")
|
||||
assert config.json()["cost_counters"] == {"runs_created": 1, "runs_completed": 0}
|
||||
|
||||
|
||||
async def test_authenticated_run_lifecycle(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await client.post(
|
||||
"/api/runs",
|
||||
json={"question": "How do engines run?", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
for _ in range(200):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "awaiting_approval":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
approved = await client.post(f"/api/runs/{run_id}/approve", headers=_HEADERS)
|
||||
assert approved.status_code == 200
|
||||
for _ in range(200):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "done":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
report = await client.get(f"/api/runs/{run_id}/report")
|
||||
assert report.status_code == 200
|
||||
assert "[p-1]" in report.json()["markdown"]
|
||||
|
||||
|
||||
async def test_no_password_hash_in_any_response(tmp_path: Path) -> None:
|
||||
"""Every response body of a full auth + run session, scanned."""
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
responses = [
|
||||
await client.get("/api/auth/me"),
|
||||
await client.get("/api/admin/users"),
|
||||
await client.get("/api/methodologies"),
|
||||
await client.get("/api/config"),
|
||||
await client.get("/api/runs/abcd2345"),
|
||||
]
|
||||
for response in responses:
|
||||
raw = response.text
|
||||
assert "$argon2" not in raw # no hash material, ever
|
||||
assert '"password_hash"' not in raw # no hash field, ever
|
||||
assert _ADMIN_PASSWORD not in raw
|
||||
assert _SECOND_PASSWORD not in raw
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,9 +4,15 @@ from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.storage import StorageAdapter
|
||||
from researchx.adapters.types import AdapterError
|
||||
|
||||
_OWNER = "u1a2b3c4"
|
||||
|
||||
|
||||
def _storage(tmp_path: Path) -> LocalStorageAdapter:
|
||||
return LocalStorageAdapter(base_dir=tmp_path, owner_id=_OWNER)
|
||||
|
||||
|
||||
async def test_storage_round_trip(tmp_path: Path) -> None:
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path)
|
||||
storage = _storage(tmp_path)
|
||||
assert isinstance(storage, StorageAdapter)
|
||||
target = await storage.write_run_artifact("abcd2345", "state.json", '{"state": "PLANNING"}')
|
||||
assert target.exists()
|
||||
@@ -15,24 +21,52 @@ async def test_storage_round_trip(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
async def test_atomic_overwrite(tmp_path: Path) -> None:
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path)
|
||||
storage = _storage(tmp_path)
|
||||
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
|
||||
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 2}')
|
||||
assert await storage.read_run_artifact("abcd2345", "state.json") == '{"v": 2}'
|
||||
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
|
||||
leftovers = list((tmp_path / "runs" / _OWNER / "abcd2345").glob("*.tmp"))
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
async def test_run_dir_layout(tmp_path: Path) -> None:
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path)
|
||||
storage = _storage(tmp_path)
|
||||
run_dir = storage.run_dir("abcd2345")
|
||||
assert run_dir == tmp_path / "runs" / "abcd2345"
|
||||
# D-106: runs/<owner_id>/<run_id>/ partitioning (supersedes D-010).
|
||||
assert run_dir == tmp_path / "runs" / _OWNER / "abcd2345"
|
||||
await storage.write_run_artifact("abcd2345", "report.md", "# Report")
|
||||
assert run_dir.is_dir()
|
||||
|
||||
|
||||
async def test_owner_id_required_and_validated(tmp_path: Path) -> None:
|
||||
for bad_owner in ["", "short", "waytoolongid123", "ABCD2345", "a b c d e", "../evil"]:
|
||||
try:
|
||||
LocalStorageAdapter(base_dir=tmp_path, owner_id=bad_owner)
|
||||
except AdapterError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"expected AdapterError for owner_id: {bad_owner!r}")
|
||||
# owner_id is required — no default partition key.
|
||||
try:
|
||||
LocalStorageAdapter(base_dir=tmp_path) # type: ignore[call-arg]
|
||||
except TypeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("owner_id must be a required positional parameter")
|
||||
|
||||
|
||||
async def test_read_missing_error_embeds_owner_path(tmp_path: Path) -> None:
|
||||
storage = _storage(tmp_path)
|
||||
try:
|
||||
await storage.read_run_artifact("zzzz9999", "state.json")
|
||||
except AdapterError as exc:
|
||||
assert f"runs/{_OWNER}/zzzz9999/state.json" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected AdapterError")
|
||||
|
||||
|
||||
async def test_read_missing_raises(tmp_path: Path) -> None:
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path)
|
||||
storage = _storage(tmp_path)
|
||||
try:
|
||||
await storage.read_run_artifact("zzzz9999", "state.json")
|
||||
except AdapterError:
|
||||
@@ -42,7 +76,7 @@ async def test_read_missing_raises(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
async def test_path_traversal_rejected(tmp_path: Path) -> None:
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path)
|
||||
storage = _storage(tmp_path)
|
||||
for bad_name in ["../escape.md", "/etc/passwd", "a/b.md", "", "."]:
|
||||
try:
|
||||
await storage.write_run_artifact("abcd2345", bad_name, "x")
|
||||
@@ -60,7 +94,33 @@ async def test_path_traversal_rejected(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
async def test_no_tmp_leftover_after_write(tmp_path: Path) -> None:
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path)
|
||||
storage = _storage(tmp_path)
|
||||
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
|
||||
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
|
||||
assert leftovers == []
|
||||
leftovers = list((tmp_path / "runs" / _OWNER / "abcd2345").glob("*.tmp"))
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
async def test_partition_isolation_between_owners(tmp_path: Path) -> None:
|
||||
alice = LocalStorageAdapter(base_dir=tmp_path, owner_id="aaaa1111")
|
||||
bob = LocalStorageAdapter(base_dir=tmp_path, owner_id="bbbb2222")
|
||||
await alice.write_run_artifact("abcd2345", "state.json", '{"owner": "alice"}')
|
||||
try:
|
||||
await bob.read_run_artifact("abcd2345", "state.json")
|
||||
except AdapterError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("owner partitions must be isolated on disk")
|
||||
|
||||
|
||||
async def test_list_run_ids_scans_owner_partition(tmp_path: Path) -> None:
|
||||
storage = _storage(tmp_path)
|
||||
assert storage.list_run_ids() == []
|
||||
await storage.write_run_artifact("abcd2345", "state.json", "{}")
|
||||
await storage.write_run_artifact("efgh6789", "state.json", "{}")
|
||||
# A file (not a dir) and dotfiles are skipped defensively.
|
||||
(tmp_path / "runs" / _OWNER / "notadir.md").write_text("x")
|
||||
(tmp_path / "runs" / _OWNER / ".hidden").mkdir()
|
||||
assert storage.list_run_ids() == ["abcd2345", "efgh6789"]
|
||||
|
||||
other = LocalStorageAdapter(base_dir=tmp_path, owner_id="zzzz9999")
|
||||
assert other.list_run_ids() == [] # separate partition, empty
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+153
-1
@@ -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" },
|
||||
|
||||
@@ -66,6 +66,36 @@ hosts is wayfinding and accumulated research:
|
||||
| `pnpm typecheck` | `tsc --noEmit` (TypeScript strict) |
|
||||
| `pnpm test` | Vitest run (jsdom + Testing Library) |
|
||||
| `pnpm test:watch` | Vitest in watch mode |
|
||||
| `pnpm generate:api` | Regenerate `src/lib/api/generated/schema.d.ts` from the committed `openapi.json` snapshot (G-005 — never from a live server) |
|
||||
| `pnpm check:api-drift` | Schema-drift gate: regenerates from the snapshot and diffs (exit 1 on drift — G-005) |
|
||||
|
||||
## Auth + sessions (v0.3 P1)
|
||||
|
||||
The ApiClient boundary gained the session surface (C3-18 — the
|
||||
exhaustive list): `login` / `logout` / `getSession` plus the admin
|
||||
hooks `listUsers` / `createUser` / `deactivateUser`.
|
||||
|
||||
- **Mock mode** (the default): a standalone dev fixture — login accepts
|
||||
any non-empty credentials and swaps an in-memory session (the dev
|
||||
admin `dev00000`), admin hooks return small fixture arrays, and the
|
||||
app is never gated behind a login (REQ-31/REQ-54).
|
||||
- **Live mode** (`NEXT_PUBLIC_RESEARCHX_API_MODE=live`): real routes —
|
||||
`POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`
|
||||
(401 → null), `GET /api/admin/users`, `POST /api/admin/users`,
|
||||
`POST /api/admin/users/{user_id}/deactivate`. Every mutating request
|
||||
carries the `X-ResearchX-Request` CSRF header (C3-05) and every
|
||||
request sends `credentials: "include"` so the httpOnly session
|
||||
cookie rides along. A failed login rejects with a typed `AuthError`
|
||||
carrying the backend's uniform 401 copy.
|
||||
|
||||
The live-mode route guard (`SessionGate` in the root layout) bootstraps
|
||||
the session on load: an unauthenticated visitor is redirected to
|
||||
`/login` before any protected content renders; the login screen itself
|
||||
is never gated. Mock mode skips the check entirely — same components,
|
||||
zero mock-mode behavior change. Dev-live origins must share the
|
||||
`localhost` hostname on both sides (`http://localhost:8000` backend +
|
||||
`http://localhost:3000` frontend — the port is not part of the
|
||||
same-site computation).
|
||||
|
||||
## Architecture notes
|
||||
|
||||
|
||||
+380
-1
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { AppTopBar } from "@/components/app-top-bar";
|
||||
import { SessionGate } from "@/components/session-gate";
|
||||
|
||||
/**
|
||||
* Root layout (T-5.2, REQ-63) — the persistent app shell.
|
||||
@@ -10,6 +11,10 @@ import { AppTopBar } from "@/components/app-top-bar";
|
||||
* slots) and top bar (⌘K palette + avatar). The shell adds zero
|
||||
* required clicks to the happy path — it is pure wayfinding chrome.
|
||||
* Page content lands in one <main> landmark per route (a11y).
|
||||
*
|
||||
* v0.3 P1 (T-1.10): the SessionGate wraps the shell in live mode —
|
||||
* an unauthenticated visitor is redirected to /login before any
|
||||
* protected content renders. Mock mode passes straight through.
|
||||
*/
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -26,13 +31,15 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="flex min-h-dvh">
|
||||
<AppSidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<AppTopBar />
|
||||
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
|
||||
<SessionGate>
|
||||
<div className="flex min-h-dvh">
|
||||
<AppSidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<AppTopBar />
|
||||
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SessionGate>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LoginView } from "@/components/login-view";
|
||||
|
||||
/**
|
||||
* /login (T-1.10, REQ-69) — the session entry point in live mode.
|
||||
* Live-mode users land here via the app-shell guard when no session
|
||||
* exists; mock mode never routes here (no auth — REQ-31).
|
||||
*/
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign in — ResearchX",
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginView />;
|
||||
}
|
||||
@@ -180,4 +180,19 @@ describe("UserMenu (REQ-66)", () => {
|
||||
expect(await screen.findByText("Researcher")).toBeInTheDocument();
|
||||
expect(screen.getByText("Local workspace — single user")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("logout calls the boundary then routes to /login (T-1.10, REQ-69)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<UserMenu onNavigate={() => {}} onDismiss={onDismiss} />,
|
||||
);
|
||||
const logout = await screen.findByRole("menuitem", { name: /Log out/i });
|
||||
expect(logout).toBeInTheDocument();
|
||||
await user.click(logout);
|
||||
await vi.waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,8 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
type DisplayProfile,
|
||||
type ReportLibrary,
|
||||
type UserProfile,
|
||||
} from "@/lib/api/client";
|
||||
import { CommandPalette, type PaletteEntry } from "@/components/command-palette";
|
||||
import { UserMenu } from "@/components/user-menu";
|
||||
@@ -121,7 +121,7 @@ const AvatarButton = forwardRef<HTMLButtonElement, {
|
||||
menuOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}>(function AvatarButton({ menuOpen, onToggle }, ref) {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [profile, setProfile] = useState<DisplayProfile | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* LoginView tests (T-1.10, REQ-69).
|
||||
*
|
||||
* - Renders the email + password form with a disabled submit until both
|
||||
* fields are filled
|
||||
* - Submit routes through the boundary: success → /ask; failure → the
|
||||
* uniform "invalid email or password" error state (401, no
|
||||
* user enumeration)
|
||||
*
|
||||
* The house pattern mocks NO boundary — instead this suite injects a
|
||||
* boundary-shaped stub through module interception because login is
|
||||
* the one call whose FAILURE path must be driven deterministically
|
||||
* (the mock binding never fails a login). The stub speaks only the
|
||||
* ApiClient surface.
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { LoginView } from "@/components/login-view";
|
||||
import { AuthError, type ApiClient } from "@/lib/api/client";
|
||||
|
||||
const pushMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
usePathname: () => "/login",
|
||||
}));
|
||||
|
||||
/** A boundary-shaped stub driving success/failure of login (REQ-69). */
|
||||
const loginMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api/client", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
Promise<typeof import("@/lib/api/client")>
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...(actual.api as unknown as ApiClient),
|
||||
login: (...args: [string, string]) => loginMock(...args),
|
||||
} as unknown as ApiClient,
|
||||
};
|
||||
});
|
||||
|
||||
const SESSION_USER = {
|
||||
user_id: "abcd2345",
|
||||
email: "owner@example.org",
|
||||
display_name: "Owner",
|
||||
role: "admin",
|
||||
active: true,
|
||||
created_at: "2026-09-29T12:00:00Z",
|
||||
};
|
||||
|
||||
describe("LoginView (REQ-69)", () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear();
|
||||
loginMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders email + password fields and a disabled submit until both are set", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginView />);
|
||||
|
||||
const email = screen.getByLabelText("Email");
|
||||
const password = screen.getByLabelText("Password");
|
||||
const submit = screen.getByRole("button", { name: "Sign in" });
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await user.type(email, "owner@example.org");
|
||||
expect(submit).toBeDisabled(); // password still empty
|
||||
await user.type(password, "not-a-real-secret");
|
||||
expect(submit).toBeEnabled();
|
||||
});
|
||||
|
||||
it("submits through the boundary and redirects to /ask on success", async () => {
|
||||
loginMock.mockResolvedValueOnce(SESSION_USER);
|
||||
const user = userEvent.setup();
|
||||
render(<LoginView />);
|
||||
|
||||
await user.type(screen.getByLabelText("Email"), "owner@example.org");
|
||||
await user.type(
|
||||
screen.getByLabelText("Password"),
|
||||
"correct-horse-battery",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
expect(loginMock).toHaveBeenCalledWith(
|
||||
"owner@example.org",
|
||||
"correct-horse-battery",
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/ask");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the uniform 'invalid email or password' error on a rejected login", async () => {
|
||||
// The backend answers every failure mode uniformly (REQ-69).
|
||||
loginMock.mockRejectedValueOnce(new AuthError("invalid email or password", 401));
|
||||
const user = userEvent.setup();
|
||||
render(<LoginView />);
|
||||
|
||||
await user.type(screen.getByLabelText("Email"), "owner@example.org");
|
||||
await user.type(screen.getByLabelText("Password"), "wrong-password");
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"invalid email or password",
|
||||
);
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
// The submit re-enables for a retry.
|
||||
expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("surfaces an honest unavailable state on a non-auth failure", async () => {
|
||||
loginMock.mockRejectedValueOnce(new Error("network down"));
|
||||
const user = userEvent.setup();
|
||||
render(<LoginView />);
|
||||
|
||||
await user.type(screen.getByLabelText("Email"), "owner@example.org");
|
||||
await user.type(screen.getByLabelText("Password"), "whatever");
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"sign-in unavailable",
|
||||
);
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LoginView (T-1.10, REQ-69) — the /login screen client.
|
||||
*
|
||||
* Email + password against POST /api/auth/login through the ApiClient
|
||||
* boundary. On success the app lands on /ask (the front door); on 401
|
||||
* the error is the backend's uniform "invalid email or password" — no
|
||||
* user enumeration, client-side included. A first-run hint points at
|
||||
* the backend's register-once rule (the first user is admin).
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, AuthError } from "@/lib/api/client";
|
||||
|
||||
export function LoginView() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSubmit = email.trim() !== "" && password !== "" && !submitting;
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
api
|
||||
.login(email.trim(), password)
|
||||
.then(() => {
|
||||
router.push("/ask");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Uniform 401 copy — the backend never says WHICH half failed.
|
||||
if (err instanceof AuthError) {
|
||||
setError("invalid email or password");
|
||||
} else {
|
||||
setError("sign-in unavailable — try again");
|
||||
}
|
||||
setSubmitting(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col px-6 py-section">
|
||||
<header className="mb-block">
|
||||
<h1 className="text-display font-semibold text-ink">Sign in.</h1>
|
||||
<p className="measure-reading mt-4 text-lead text-ink-soft">
|
||||
ResearchX runs under your account — your research library,
|
||||
reports, and settings are yours alone.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<label htmlFor="email" className="block text-body font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="you@example.org"
|
||||
className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
|
||||
/>
|
||||
|
||||
<label htmlFor="password" className="mt-6 block text-body font-medium">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••••••"
|
||||
className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
|
||||
/>
|
||||
|
||||
{error !== null && (
|
||||
<p role="alert" className="mt-4 text-small text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="mt-8 rounded-pill bg-accent px-7 py-3 text-body font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{submitting ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-block text-caption text-ink-faint">
|
||||
First run? The first account created becomes the administrator.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* SessionGate tests (T-1.10, REQ-69) — the live-mode route guard.
|
||||
*
|
||||
* The gate consults the boundary's getSession() on mount:
|
||||
* - live mode + no session → redirect to /login
|
||||
* - live mode + session → content renders
|
||||
* - live mode + /login route → never gated (the login screen itself)
|
||||
* - mock mode → no getSession call at all, content
|
||||
* renders immediately (REQ-31/REQ-54)
|
||||
*
|
||||
* Mode is a module constant resolved from env at import time, so this
|
||||
* suite exercises the gate's decision logic with the boundary stubbed
|
||||
* per case; the mock-mode fast path is pinned against the REAL
|
||||
* binding (the default mock singleton).
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionGate } from "@/components/session-gate";
|
||||
import type { ApiClient } from "@/lib/api/client";
|
||||
|
||||
const replaceMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: replaceMock }),
|
||||
usePathname: () => "/ask",
|
||||
}));
|
||||
|
||||
const getSessionMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api/client", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
Promise<typeof import("@/lib/api/client")>
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
liveMode: true, // the gate only acts in live mode
|
||||
api: {
|
||||
...(actual.api as unknown as ApiClient),
|
||||
getSession: () => getSessionMock(),
|
||||
} as unknown as ApiClient,
|
||||
};
|
||||
});
|
||||
|
||||
const SESSION_USER = {
|
||||
user_id: "abcd2345",
|
||||
email: "owner@example.org",
|
||||
display_name: "Owner",
|
||||
role: "admin",
|
||||
active: true,
|
||||
created_at: "2026-09-29T12:00:00Z",
|
||||
};
|
||||
|
||||
describe("SessionGate (T-1.10, REQ-69)", () => {
|
||||
beforeEach(() => {
|
||||
replaceMock.mockClear();
|
||||
getSessionMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("redirects an unauthenticated visitor to /login before content renders", async () => {
|
||||
// Persistent mock: React StrictMode double-invokes effects in tests,
|
||||
// so the boundary stub must answer every call the same way.
|
||||
getSessionMock.mockResolvedValue(null);
|
||||
render(
|
||||
<SessionGate>
|
||||
<p>Protected screen</p>
|
||||
</SessionGate>,
|
||||
);
|
||||
// Guard content never flashes for an unauthenticated visitor.
|
||||
expect(screen.queryByText("Protected screen")).toBeNull();
|
||||
await vi.waitFor(() => {
|
||||
expect(replaceMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
expect(getSessionMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the shell once a session resolves", async () => {
|
||||
getSessionMock.mockResolvedValue(SESSION_USER);
|
||||
render(
|
||||
<SessionGate>
|
||||
<p>Protected screen</p>
|
||||
</SessionGate>,
|
||||
);
|
||||
expect(await screen.findByText("Protected screen")).toBeInTheDocument();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when the session probe errors", async () => {
|
||||
getSessionMock.mockRejectedValue(new Error("backend unreachable"));
|
||||
render(
|
||||
<SessionGate>
|
||||
<p>Protected screen</p>
|
||||
</SessionGate>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(replaceMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
expect(screen.queryByText("Protected screen")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SessionGate (T-1.10, REQ-69) — the live-mode route guard.
|
||||
*
|
||||
* A session bootstrap in the app shell: on mount it asks the boundary
|
||||
* for the current session; in live mode an unauthenticated user is
|
||||
* redirected to /login. Mock mode skips the check entirely (the mock
|
||||
* is a standalone dev/test fixture with no auth — REQ-31/REQ-54), so
|
||||
* its behavior stays byte-for-byte unchanged.
|
||||
*
|
||||
* While the session resolves the screen is blank rather than flashing
|
||||
* protected content at an unauthenticated visitor (no data ever
|
||||
* renders before the guard clears).
|
||||
*/
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, liveMode } from "@/lib/api/client";
|
||||
|
||||
export function SessionGate({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname() ?? "";
|
||||
// The login screen itself is never gated (nor redirected).
|
||||
const isLoginRoute = pathname === "/login";
|
||||
// Mock mode never gates: render immediately, no getSession() call.
|
||||
const [ready, setReady] = useState(!liveMode || isLoginRoute);
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveMode || isLoginRoute) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.getSession()
|
||||
.then((session) => {
|
||||
if (cancelled) return;
|
||||
if (session === null) {
|
||||
router.replace("/login");
|
||||
} else {
|
||||
setReady(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// A guard that cannot ask the backend fails closed.
|
||||
if (!cancelled) router.replace("/login");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router, isLoginRoute]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center">
|
||||
<p className="text-caption text-ink-faint">Loading…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* UserMenu session display tests (T-1.10, REQ-69) — the avatar menu
|
||||
* shows the REAL profile in live mode (display name + email from
|
||||
* getSession) and keeps the fixture in mock mode, plus Logout wiring.
|
||||
*
|
||||
* The menu consults the boundary twice: getUserProfile() for the
|
||||
* fixture display and getSession() for the live account. This suite
|
||||
* stubs both through the ApiClient surface, driving live mode with a
|
||||
* resolved session and mock mode with the real mock binding.
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserMenu } from "@/components/user-menu";
|
||||
import type { ApiClient, UserProfile } from "@/lib/api/client";
|
||||
|
||||
const pushMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
usePathname: () => "/ask",
|
||||
}));
|
||||
|
||||
// Boundary + mode injection: the suite swaps the module's `api` and
|
||||
// `liveMode` exports per case (the menu reads both). The mock's `api`
|
||||
// delegates to state.api at CALL time so re-assignment per test works.
|
||||
const state = vi.hoisted(() => ({
|
||||
api: {} as Record<string, unknown>,
|
||||
liveMode: false,
|
||||
}));
|
||||
vi.mock("@/lib/api/client", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
Promise<typeof import("@/lib/api/client")>
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
api: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
const handler = state.api[prop as string];
|
||||
return typeof handler === "function"
|
||||
? (handler as (...args: unknown[]) => unknown).bind(state.api)
|
||||
: handler;
|
||||
},
|
||||
},
|
||||
),
|
||||
// Getter export: the menu reads liveMode at render time, so the
|
||||
// per-test assignment below must be visible.
|
||||
get liveMode() {
|
||||
return state.liveMode;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SESSION_USER: UserProfile = {
|
||||
user_id: "abcd2345",
|
||||
email: "owner@example.org",
|
||||
display_name: "Ada Owner",
|
||||
role: "admin",
|
||||
active: true,
|
||||
created_at: "2026-09-29T12:00:00Z",
|
||||
};
|
||||
|
||||
/** Boundary-shaped stub with an injectable live-mode flag + session. */
|
||||
function makeApi(overrides: {
|
||||
session: UserProfile | null;
|
||||
logout: () => Promise<void>;
|
||||
}): ApiClient {
|
||||
return {
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(overrides.logout),
|
||||
getSession: vi.fn().mockResolvedValue(overrides.session),
|
||||
listUsers: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
deactivateUser: vi.fn(),
|
||||
getUserProfile: vi.fn().mockResolvedValue({
|
||||
name: "Researcher",
|
||||
initials: "RS",
|
||||
tagline: "Local workspace — single user",
|
||||
}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe("UserMenu session display (T-1.10, REQ-69)", () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("live mode shows the real session profile — display name + email", async () => {
|
||||
state.api = makeApi({ session: SESSION_USER, logout: vi.fn() }) as unknown as Record<string, unknown>;
|
||||
state.liveMode = true;
|
||||
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
|
||||
expect(await screen.findByText("Ada Owner")).toBeInTheDocument();
|
||||
expect(screen.getByText("owner@example.org")).toBeInTheDocument();
|
||||
// The avatar disc carries the real initials.
|
||||
expect(screen.getByText("AO")).toBeInTheDocument();
|
||||
// Never the fixture name in live mode with a resolved session.
|
||||
expect(screen.queryByText("Researcher")).toBeNull();
|
||||
});
|
||||
|
||||
it("mock mode keeps the fixture profile unchanged", async () => {
|
||||
state.api = makeApi({ session: SESSION_USER, logout: vi.fn() }) as unknown as Record<string, unknown>;
|
||||
state.liveMode = false;
|
||||
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
|
||||
expect(await screen.findByText("Researcher")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Local workspace — single user"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("logout calls the boundary then routes to /login", async () => {
|
||||
const logout = vi.fn().mockResolvedValue(undefined);
|
||||
const onDismiss = vi.fn();
|
||||
state.api = makeApi({ session: SESSION_USER, logout }) as unknown as Record<string, unknown>;
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<UserMenu onNavigate={() => {}} onDismiss={onDismiss} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", { name: /Log out/i }),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(logout).toHaveBeenCalledTimes(1);
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("a failed logout still routes away (the cookie dies server-side)", async () => {
|
||||
const logout = vi.fn().mockRejectedValue(new Error("network down"));
|
||||
state.api = makeApi({ session: SESSION_USER, logout }) as unknown as Record<string, unknown>;
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", { name: /Log out/i }),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* UserMenu (T-5.2, REQ-66) — the avatar button's dropdown.
|
||||
* UserMenu (T-5.2, REQ-66; T-1.10, REQ-69) — the avatar button's dropdown.
|
||||
*
|
||||
* Local single-user profile (no auth — single-user constraint), links to
|
||||
* Settings and About (the settings page's about section). Escape and
|
||||
* outside clicks dismiss. Rendered as a menu for assistive tech.
|
||||
* Shows the profile: the real session user (display name + email via
|
||||
* getSession) in live mode, the local fixture in mock mode — and a
|
||||
* Logout action (revokes the session through the boundary, routes to
|
||||
* /login). Escape and outside clicks dismiss. Rendered as a menu for
|
||||
* assistive tech.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { api, type UserProfile } from "@/lib/api/client";
|
||||
import { api, liveMode, type DisplayProfile, type UserProfile } from "@/lib/api/client";
|
||||
|
||||
export function UserMenu({
|
||||
onNavigate,
|
||||
@@ -22,7 +25,10 @@ export function UserMenu({
|
||||
/** The avatar button — clicks on it toggle, not dismiss-then-reopen. */
|
||||
toggleRef?: RefObject<HTMLButtonElement | null>;
|
||||
}) {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const router = useRouter();
|
||||
const [profile, setProfile] = useState<DisplayProfile | null>(null);
|
||||
const [session, setSession] = useState<UserProfile | null>(null);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,11 +41,52 @@ export function UserMenu({
|
||||
.catch(() => {
|
||||
/* fallback initials render below */
|
||||
});
|
||||
// The real session user drives the live-mode display (T-1.10);
|
||||
// mock mode ignores it and keeps the fixture.
|
||||
api
|
||||
.getSession()
|
||||
.then((user) => {
|
||||
if (!cancelled) setSession(user);
|
||||
})
|
||||
.catch(() => {
|
||||
/* menu falls back to the fixture display */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Live mode with a resolved session shows the real account; mock
|
||||
// mode (and any unresolved session) shows the fixture unchanged.
|
||||
const showSession = liveMode && session !== null;
|
||||
const name = showSession
|
||||
? (session as UserProfile).display_name
|
||||
: (profile?.name ?? "Researcher");
|
||||
const detail = showSession
|
||||
? (session as UserProfile).email
|
||||
: (profile?.tagline ?? "Local workspace");
|
||||
const initials = showSession
|
||||
? initialsOf((session as UserProfile).display_name)
|
||||
: (profile?.initials ?? "··");
|
||||
|
||||
// Logout: revoke the session through the boundary, then land on
|
||||
// /login (live mode). Mock mode keeps the identical control — its
|
||||
// logout is a local no-op that still routes, so the surface behaves
|
||||
// the same in both bindings.
|
||||
const handleLogout = () => {
|
||||
if (loggingOut) return;
|
||||
setLoggingOut(true);
|
||||
api
|
||||
.logout()
|
||||
.catch(() => {
|
||||
/* a failed revoke still leaves the app — the cookie dies server-side soonest */
|
||||
})
|
||||
.finally(() => {
|
||||
onDismiss();
|
||||
router.push("/login");
|
||||
});
|
||||
};
|
||||
|
||||
// Escape closes; outside clicks close. Focus stays in the header.
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
@@ -72,15 +119,11 @@ export function UserMenu({
|
||||
aria-hidden="true"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-pill bg-accent text-small font-semibold text-paper-raised"
|
||||
>
|
||||
{profile?.initials ?? "··"}
|
||||
{initials}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-small font-medium text-ink">
|
||||
{profile?.name ?? "Researcher"}
|
||||
</p>
|
||||
<p className="text-caption text-ink-faint">
|
||||
{profile?.tagline ?? "Local workspace"}
|
||||
</p>
|
||||
<p className="text-small font-medium text-ink">{name}</p>
|
||||
<p className="text-caption text-ink-faint">{detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,11 +148,33 @@ export function UserMenu({
|
||||
About & version
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={handleLogout}
|
||||
disabled={loggingOut}
|
||||
className="mt-1 w-full rounded-card px-2 py-2 text-left text-small text-ink transition-colors hover:bg-paper-sunken disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{loggingOut ? "Signing out…" : "Log out"}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="mt-3 border-t border-hairline pt-3 text-caption text-ink-faint">
|
||||
Local profile — accounts and sync land post-MVP.
|
||||
{showSession
|
||||
? `Signed in as ${session?.role} — sessions slide for 14 days.`
|
||||
: "Local profile — accounts and sync land post-MVP."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Two-letter initials from a display name ("Dev User" → "DU"). */
|
||||
function initialsOf(displayName: string): string {
|
||||
const words = displayName.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return "··";
|
||||
const first = words[0]?.[0] ?? "";
|
||||
const second = words[1]?.[0] ?? words[0]?.[1] ?? "";
|
||||
return `${first}${second}`.toUpperCase();
|
||||
}
|
||||
@@ -10,7 +10,9 @@
|
||||
import type {
|
||||
CitationLedger,
|
||||
ConnectionSlot,
|
||||
CreateUserInput,
|
||||
DialValues,
|
||||
DisplayProfile,
|
||||
InboxItem,
|
||||
Methodology,
|
||||
ProjectGraph,
|
||||
@@ -33,7 +35,9 @@ export type {
|
||||
Citation,
|
||||
CitationLedger,
|
||||
ConnectionSlot,
|
||||
CreateUserInput,
|
||||
DialValues,
|
||||
DisplayProfile,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
InboxItem,
|
||||
@@ -153,8 +157,11 @@ export interface ApiClient {
|
||||
*/
|
||||
getReportConnections(): Promise<ReportConnection[]>;
|
||||
|
||||
/** The local single-user profile behind the avatar (REQ-66). */
|
||||
getUserProfile(): Promise<UserProfile>;
|
||||
/**
|
||||
* The menu/display profile behind the avatar (REQ-66). In live mode
|
||||
* this derives from the session user; in mock mode it is the fixture.
|
||||
*/
|
||||
getUserProfile(): Promise<DisplayProfile>;
|
||||
|
||||
/** Adapter-slot connection status for the shell + settings (REQ-65). */
|
||||
getConnectionStatus(): Promise<ConnectionSlot[]>;
|
||||
@@ -191,8 +198,68 @@ export interface ApiClient {
|
||||
draft: string,
|
||||
answers: Record<string, string>,
|
||||
): Promise<RefinedQuestion>;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* v0.3 P1 session surface (C3-18 exhaustive list — REQ-69, REQ-70). */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Authenticate with email + password (POST /api/auth/login — sets
|
||||
* the httpOnly session cookie server-side). Rejects with a typed
|
||||
* AuthError on 401 (uniform "invalid email or password").
|
||||
*/
|
||||
login(email: string, password: string): Promise<UserProfile>;
|
||||
|
||||
/** Revoke the current session (POST /api/auth/logout). */
|
||||
logout(): Promise<void>;
|
||||
|
||||
/**
|
||||
* The current session's user, or null when unauthenticated
|
||||
* (GET /api/auth/me — 401 maps to null, never a throw).
|
||||
*/
|
||||
getSession(): Promise<UserProfile | null>;
|
||||
|
||||
/** Admin hook: list all users (GET /api/admin/users — REQ-70). */
|
||||
listUsers(): Promise<UserProfile[]>;
|
||||
|
||||
/** Admin hook: create a user with an admin-set password (POST /api/admin/users). */
|
||||
createUser(input: CreateUserInput): Promise<UserProfile>;
|
||||
|
||||
/**
|
||||
* Admin hook: deactivate a user — kills their sessions, retains
|
||||
* their data (POST /api/admin/users/{user_id}/deactivate).
|
||||
*/
|
||||
deactivateUser(user_id: string): Promise<UserProfile>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed auth failure (T-1.10, REQ-69) — thrown by `login` on 401.
|
||||
* The backend answers every failure mode uniformly ("invalid email or
|
||||
* password" — no user enumeration); the client carries the same
|
||||
* honesty through to the login screen.
|
||||
*/
|
||||
export class AuthError extends Error {
|
||||
/** HTTP status of the failed auth request (401 for bad credentials). */
|
||||
readonly status: number;
|
||||
|
||||
constructor(message = "invalid email or password", status = 401) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app is bound to the live backend (auth applies) or to
|
||||
* the mock fixture (dev/test — no login, no redirect; REQ-31/REQ-54).
|
||||
* The login route guard and session bootstrap consult this flag so
|
||||
* mock mode's zero-auth behavior stays byte-for-byte unchanged.
|
||||
*/
|
||||
export const liveMode: boolean =
|
||||
(process.env.NEXT_PUBLIC_RESEARCHX_API_MODE ??
|
||||
process.env.RESEARCHX_API_MODE ??
|
||||
"mock") === "live";
|
||||
|
||||
/**
|
||||
* The app-wide singleton. v0.1: MockApiClient (fixture data + scripted
|
||||
* event replay, zero API keys). v0.2 P3 (REQ-26/REQ-54): the binding
|
||||
|
||||
@@ -161,11 +161,16 @@ describe("E2E happy path — real stack, defaults only (REQ-17)", () => {
|
||||
}
|
||||
}, 55_000);
|
||||
});
|
||||
// The engine's terminal signal arrived on the stream: onDone only
|
||||
// fires after the run_done event (the stream closes on it).
|
||||
expect(done).toBe(true);
|
||||
expect(events).toContain("status");
|
||||
|
||||
const final = await client.getRun(run.id);
|
||||
expect(final.status).toBe("done");
|
||||
// P-6 (registry pruning, T-1.8): the terminal run's registry entry
|
||||
// is dropped once its last SSE subscriber leaves. A run GET after
|
||||
// the stream 404s — documented acceptable; the trace persists on
|
||||
// disk (the events-404-after-prune class, pinned here end to end).
|
||||
await expect(client.getRun(run.id)).rejects.toThrow(/failed to load run/);
|
||||
expect(userActions).toBeLessThanOrEqual(3);
|
||||
}, 90_000);
|
||||
|
||||
|
||||
+391
@@ -4,6 +4,126 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/healthz": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Healthz */
|
||||
get: operations["healthz_healthz_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/register": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Register */
|
||||
post: operations["register_api_auth_register_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Login */
|
||||
post: operations["login_api_auth_login_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/logout": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Logout */
|
||||
post: operations["logout_api_auth_logout_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/me": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Me */
|
||||
get: operations["me_api_auth_me_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/users": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Users */
|
||||
get: operations["list_users_api_admin_users_get"];
|
||||
put?: never;
|
||||
/** Create User */
|
||||
post: operations["create_user_api_admin_users_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/users/{user_id}/deactivate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Deactivate User */
|
||||
post: operations["deactivate_user_api_admin_users__user_id__deactivate_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -195,6 +315,23 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** AdminCreateUserBody */
|
||||
AdminCreateUserBody: {
|
||||
/** Email */
|
||||
email: string;
|
||||
/** Password */
|
||||
password: string;
|
||||
/**
|
||||
* Display Name
|
||||
* @default
|
||||
*/
|
||||
display_name: string;
|
||||
/**
|
||||
* Role
|
||||
* @default user
|
||||
*/
|
||||
role: string;
|
||||
};
|
||||
/** ApproveBody */
|
||||
ApproveBody: {
|
||||
/** Plan */
|
||||
@@ -240,6 +377,13 @@ export interface components {
|
||||
/** Detail */
|
||||
detail?: components["schemas"]["ValidationError"][];
|
||||
};
|
||||
/** LoginBody */
|
||||
LoginBody: {
|
||||
/** Email */
|
||||
email: string;
|
||||
/** Password */
|
||||
password: string;
|
||||
};
|
||||
/** MethodologyCard */
|
||||
MethodologyCard: {
|
||||
/** Slug */
|
||||
@@ -261,6 +405,18 @@ export interface components {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
/** RegisterBody */
|
||||
RegisterBody: {
|
||||
/** Email */
|
||||
email: string;
|
||||
/** Password */
|
||||
password: string;
|
||||
/**
|
||||
* Display Name
|
||||
* @default
|
||||
*/
|
||||
display_name: string;
|
||||
};
|
||||
/** RunSummary */
|
||||
RunSummary: {
|
||||
/** Id */
|
||||
@@ -278,6 +434,27 @@ export interface components {
|
||||
/** Started At */
|
||||
started_at: string;
|
||||
};
|
||||
/**
|
||||
* UserPublic
|
||||
* @description A user as exposed on the wire — no password_hash, ever.
|
||||
*/
|
||||
UserPublic: {
|
||||
/** User Id */
|
||||
user_id: string;
|
||||
/** Email */
|
||||
email: string;
|
||||
/** Display Name */
|
||||
display_name: string;
|
||||
/** Role */
|
||||
role: string;
|
||||
/** Active */
|
||||
active: boolean;
|
||||
/**
|
||||
* Created At
|
||||
* Format: date-time
|
||||
*/
|
||||
created_at: string;
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
@@ -296,6 +473,220 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
healthz_healthz_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
register_api_auth_register_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RegisterBody"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserPublic"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
login_api_auth_login_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["LoginBody"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserPublic"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
logout_api_auth_logout_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
me_api_auth_me_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserPublic"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_users_api_admin_users_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserPublic"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_user_api_admin_users_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminCreateUserBody"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserPublic"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
deactivate_user_api_admin_users__user_id__deactivate_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
user_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserPublic"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
config_status_api_config_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -17,15 +17,18 @@ import createClient from "openapi-fetch";
|
||||
import type { paths } from "@/lib/api/generated/schema";
|
||||
import type {
|
||||
CitationLedger,
|
||||
CreateUserInput,
|
||||
Methodology,
|
||||
Report,
|
||||
ResearchRun,
|
||||
TraceEvent,
|
||||
UserProfile,
|
||||
} from "@/lib/types";
|
||||
import type {
|
||||
CreateRunInput,
|
||||
RunEventSubscription,
|
||||
} from "@/lib/api/client";
|
||||
import { AuthError } from "@/lib/api/client";
|
||||
import { MockApiClient } from "@/lib/api/mock/mock-client";
|
||||
|
||||
function baseUrl(): string {
|
||||
@@ -34,6 +37,12 @@ function baseUrl(): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The uniform CSRF header value for every mutating request (C3-05):
|
||||
* the backend guard requires `X-ResearchX-Request` on non-GET calls.
|
||||
*/
|
||||
const CSRF_HEADER = "X-ResearchX-Request";
|
||||
|
||||
type BackendRun = {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -69,6 +78,117 @@ export class GeneratedApiClient extends MockApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Session surface (T-1.10, REQ-69/REQ-70). Fetch discipline: */
|
||||
/* - `credentials: "include"` on EVERY call — the session cookie */
|
||||
/* rides along; */
|
||||
/* - `X-ResearchX-Request: "1"` on every mutation (C3-05). */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/** Map the backend's UserPublic to the frontend UserProfile shape. */
|
||||
#toProfile(user: {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
}): UserProfile {
|
||||
return {
|
||||
user_id: user.user_id,
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
created_at: user.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
override async login(email: string, password: string): Promise<UserProfile> {
|
||||
const { data, error, response } = await this.#client.POST(
|
||||
"/api/auth/login",
|
||||
{
|
||||
body: { email, password },
|
||||
credentials: "include",
|
||||
headers: { [CSRF_HEADER]: "1" },
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
// Uniform 401 — the backend never enumerates users; neither do we.
|
||||
if (response.status === 401) {
|
||||
throw new AuthError("invalid email or password", 401);
|
||||
}
|
||||
throw new AuthError("login failed", response.status);
|
||||
}
|
||||
return this.#toProfile(data);
|
||||
}
|
||||
|
||||
override async logout(): Promise<void> {
|
||||
const { error } = await this.#client.POST("/api/auth/logout", {
|
||||
credentials: "include",
|
||||
headers: { [CSRF_HEADER]: "1" },
|
||||
});
|
||||
if (error) {
|
||||
throw new Error("live: logout failed");
|
||||
}
|
||||
}
|
||||
|
||||
override async getSession(): Promise<UserProfile | null> {
|
||||
const { data, error } = await this.#client.GET("/api/auth/me", {
|
||||
credentials: "include",
|
||||
});
|
||||
if (error || !data) {
|
||||
// 401 → null: "no session" is a state, not an error.
|
||||
return null;
|
||||
}
|
||||
return this.#toProfile(data);
|
||||
}
|
||||
|
||||
override async listUsers(): Promise<UserProfile[]> {
|
||||
const { data, error } = await this.#client.GET("/api/admin/users", {
|
||||
credentials: "include",
|
||||
});
|
||||
if (error || !data) {
|
||||
throw new Error("live: failed to list users");
|
||||
}
|
||||
return data.map((user) => this.#toProfile(user));
|
||||
}
|
||||
|
||||
override async createUser(input: CreateUserInput): Promise<UserProfile> {
|
||||
const { data, error, response } = await this.#client.POST(
|
||||
"/api/admin/users",
|
||||
{
|
||||
body: {
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
display_name: input.display_name ?? "",
|
||||
role: input.role ?? "user",
|
||||
},
|
||||
credentials: "include",
|
||||
headers: { [CSRF_HEADER]: "1" },
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
throw new Error(`live: failed to create user (${response.status})`);
|
||||
}
|
||||
return this.#toProfile(data);
|
||||
}
|
||||
|
||||
override async deactivateUser(user_id: string): Promise<UserProfile> {
|
||||
const { data, error } = await this.#client.POST(
|
||||
"/api/admin/users/{user_id}/deactivate",
|
||||
{
|
||||
params: { path: { user_id } },
|
||||
credentials: "include",
|
||||
headers: { [CSRF_HEADER]: "1" },
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
throw new Error(`live: failed to deactivate user ${user_id}`);
|
||||
}
|
||||
return this.#toProfile(data);
|
||||
}
|
||||
|
||||
override async getMethodologies(): Promise<Methodology[]> {
|
||||
const { data, error } = await this.#client.GET("/api/methodologies");
|
||||
if (error || !data) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { ApiClient, CreateRunInput } from "@/lib/api/client";
|
||||
import { AuthError } from "@/lib/api/client";
|
||||
import {
|
||||
buildSuggestedQueries,
|
||||
composeRefinedQuestion,
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
import type {
|
||||
CitationLedger,
|
||||
ConnectionSlot,
|
||||
CreateUserInput,
|
||||
DisplayProfile,
|
||||
InboxItem,
|
||||
Methodology,
|
||||
ProjectGraph,
|
||||
@@ -274,7 +277,7 @@ export class MockApiClient implements ApiClient {
|
||||
}
|
||||
|
||||
/** The local single-user profile behind the avatar (REQ-66). */
|
||||
async getUserProfile(): Promise<UserProfile> {
|
||||
async getUserProfile(): Promise<DisplayProfile> {
|
||||
return structuredClone(userProfileFixture);
|
||||
}
|
||||
|
||||
@@ -334,6 +337,92 @@ export class MockApiClient implements ApiClient {
|
||||
return composeRefinedQuestion(draft, answers);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* v0.3 P1 session surface — mock-local (T-1.10, REQ-69/REQ-70). */
|
||||
/* The mock stays a standalone zero-backend fixture: login accepts */
|
||||
/* any non-empty credentials and swaps an in-memory session in; */
|
||||
/* the dev user mirrors the backend's synthetic dev admin */
|
||||
/* (DEV_USER_ID "dev00000", guard.py). */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
#session: UserProfile | null = null;
|
||||
|
||||
/** The mock dev user — same synthetic admin the backend's dev guard resolves. */
|
||||
static readonly DEV_USER: UserProfile = {
|
||||
user_id: "dev00000",
|
||||
email: "dev@localhost",
|
||||
display_name: "Dev User",
|
||||
role: "admin",
|
||||
active: true,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
/** Small fixture user set for the admin hooks (REQ-70). */
|
||||
static readonly USERS: UserProfile[] = [
|
||||
MockApiClient.DEV_USER,
|
||||
{
|
||||
user_id: "abcd2345",
|
||||
email: "colleague@example.org",
|
||||
display_name: "Fixture Colleague",
|
||||
role: "user",
|
||||
active: true,
|
||||
created_at: "2026-02-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** Local "login" — any non-empty credentials open the dev session. */
|
||||
async login(email: string, password: string): Promise<UserProfile> {
|
||||
if (email.trim() === "" || password === "") {
|
||||
throw new AuthError();
|
||||
}
|
||||
this.#session = structuredClone(MockApiClient.DEV_USER);
|
||||
return structuredClone(MockApiClient.DEV_USER);
|
||||
}
|
||||
|
||||
/** Local "logout" — clears the in-memory session. */
|
||||
async logout(): Promise<void> {
|
||||
this.#session = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mock session is implicitly open (dev fixture): the dev user
|
||||
* when a "login" happened this session, otherwise still the dev
|
||||
* user — the mock never gates the app behind a login (REQ-31).
|
||||
*/
|
||||
async getSession(): Promise<UserProfile | null> {
|
||||
return structuredClone(this.#session ?? MockApiClient.DEV_USER);
|
||||
}
|
||||
|
||||
/** Admin hooks — small fixture arrays (REQ-70). */
|
||||
async listUsers(): Promise<UserProfile[]> {
|
||||
return structuredClone(MockApiClient.USERS);
|
||||
}
|
||||
|
||||
async createUser(input: CreateUserInput): Promise<UserProfile> {
|
||||
const created: UserProfile = {
|
||||
user_id: "newu0001",
|
||||
email: input.email,
|
||||
display_name: input.display_name ?? input.email.split("@")[0] ?? "New User",
|
||||
role: input.role ?? "user",
|
||||
active: true,
|
||||
created_at: "2026-09-29T00:00:00Z",
|
||||
};
|
||||
MockApiClient.USERS.push(created);
|
||||
return structuredClone(created);
|
||||
}
|
||||
|
||||
async deactivateUser(user_id: string): Promise<UserProfile> {
|
||||
const user = MockApiClient.USERS.find((u) => u.user_id === user_id);
|
||||
if (!user) {
|
||||
throw new Error(`mock: unknown user ${user_id}`);
|
||||
}
|
||||
user.active = false;
|
||||
if (this.#session?.user_id === user_id) {
|
||||
this.#session = null; // deactivation kills sessions
|
||||
}
|
||||
return structuredClone(user);
|
||||
}
|
||||
|
||||
/** The scripted run for the prototype's demo question. */
|
||||
#defaultRun(): ResearchRun {
|
||||
return buildScriptedRun({
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
import type { Settings } from "@/lib/types";
|
||||
import type {
|
||||
DisplayProfile,
|
||||
ReportConnection,
|
||||
ReportLibrary,
|
||||
UserProfile,
|
||||
} from "@/lib/types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -200,7 +200,7 @@ export const connectionStatusFixture: ConnectionSlot[] = [
|
||||
/* User profile (REQ-66) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const userProfileFixture: UserProfile = {
|
||||
export const userProfileFixture: DisplayProfile = {
|
||||
name: "Researcher",
|
||||
initials: "RS",
|
||||
tagline: "Local workspace — single user",
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Session-surface boundary tests (T-1.10, REQ-69, REQ-70).
|
||||
*
|
||||
* The v0.3 P1 ApiClient additions — login / logout / getSession plus
|
||||
* the admin hooks — contract-tested against BOTH bindings:
|
||||
* - the mock binding (standalone, zero backend — REQ-31/REQ-54), and
|
||||
* - the live binding against a stubbed global fetch (no network, no
|
||||
* keys), asserting the real routes, the CSRF header on every
|
||||
* mutation (C3-05), and credentials:"include" cookie discipline.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AuthError, type ApiClient } from "@/lib/api/client";
|
||||
import { MockApiClient } from "@/lib/api/mock/mock-client";
|
||||
import { GeneratedApiClient } from "@/lib/api/live-client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock binding — standalone session fixture */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("MockApiClient session surface (REQ-69/REQ-70)", () => {
|
||||
it("login accepts any non-empty credentials and returns the dev admin", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const user = await client.login("dev@localhost", "anything");
|
||||
expect(user.user_id).toBe("dev00000");
|
||||
expect(user.role).toBe("admin");
|
||||
expect(user.active).toBe(true);
|
||||
});
|
||||
|
||||
it("getSession returns the dev user (mock is never login-gated)", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const session = await client.getSession();
|
||||
expect(session?.user_id).toBe("dev00000");
|
||||
});
|
||||
|
||||
it("logout clears the session; getSession still reports the dev fixture", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
await client.login("dev@localhost", "pw");
|
||||
await client.logout();
|
||||
// The mock dev user persists as the implicit session (REQ-31)…
|
||||
expect((await client.getSession())?.user_id).toBe("dev00000");
|
||||
});
|
||||
|
||||
it("login rejects empty credentials with a typed AuthError", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
await expect(client.login("", "")).rejects.toBeInstanceOf(AuthError);
|
||||
});
|
||||
|
||||
it("admin hooks return small fixture arrays and honor deactivate", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const users = await client.listUsers();
|
||||
expect(users.length).toBeGreaterThanOrEqual(2);
|
||||
expect(users[0]?.role).toBe("admin");
|
||||
|
||||
const created = await client.createUser({
|
||||
email: "new@example.org",
|
||||
password: "not-a-real-secret",
|
||||
});
|
||||
expect(created.email).toBe("new@example.org");
|
||||
expect(created.active).toBe(true);
|
||||
|
||||
const deactivated = await client.deactivateUser(created.user_id);
|
||||
expect(deactivated.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Live binding — real routes against a stubbed fetch */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type RouteHandler = (url: string, init?: RequestInit) => { status: number; body: unknown };
|
||||
|
||||
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
|
||||
// openapi-fetch builds a Request and hands it to fetch — surface the
|
||||
// request's own headers + credentials back to the route handler so
|
||||
// tests can assert the CSRF header and cookie discipline per call.
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : undefined;
|
||||
const url = request ? request.url : String(input);
|
||||
const sentInit: RequestInit | undefined = request
|
||||
? {
|
||||
...init,
|
||||
method: request.method,
|
||||
credentials: request.credentials,
|
||||
headers: request.headers,
|
||||
}
|
||||
: init;
|
||||
for (const [prefix, handler] of Object.entries(routes)) {
|
||||
if (url.startsWith(prefix)) {
|
||||
const { status, body } = handler(url, sentInit);
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify(body404()), { status: 404 });
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
const body404 = (): unknown => ({ detail: "not found" });
|
||||
|
||||
const backendUser = {
|
||||
user_id: "abcd2345",
|
||||
email: "owner@example.org",
|
||||
display_name: "Owner",
|
||||
role: "admin",
|
||||
active: true,
|
||||
created_at: "2026-09-29T12:00:00Z",
|
||||
};
|
||||
|
||||
/** Headers actually sent on the last mutating call (CSRF + cookie assertions). */
|
||||
function mutationCapture(): {
|
||||
headersOf: (init?: RequestInit) => Record<string, string>;
|
||||
} {
|
||||
return {
|
||||
// Duck-typed: jsdom + Node fetch disagree on the Headers realm, so
|
||||
// `instanceof Headers` is false across the boundary — check shape.
|
||||
headersOf: (init?: RequestInit) => {
|
||||
const raw = init?.headers;
|
||||
const record: Record<string, string> = {};
|
||||
if (
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
typeof (raw as Headers).forEach === "function"
|
||||
) {
|
||||
(raw as Headers).forEach((v, k) => {
|
||||
record[k] = v;
|
||||
});
|
||||
} else if (Array.isArray(raw)) {
|
||||
for (const [k, v] of raw) record[String(k)] = String(v);
|
||||
} else if (raw) {
|
||||
Object.assign(record, raw);
|
||||
}
|
||||
return record;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("GeneratedApiClient session surface (live binding, REQ-69/REQ-70)", () => {
|
||||
it("login POSTs /api/auth/login with the CSRF header + credentials", async () => {
|
||||
const { headersOf } = mutationCapture();
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/auth/login": (_url: string, init?: RequestInit) => {
|
||||
const headers = headersOf(init);
|
||||
expect(headers["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
return { status: 200, body: backendUser };
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
const user = await client.login("owner@example.org", "correct-horse");
|
||||
expect(user.user_id).toBe("abcd2345");
|
||||
expect(user.email).toBe("owner@example.org");
|
||||
expect(user.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("login 401 → typed AuthError with the uniform message", async () => {
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/auth/login": () => ({
|
||||
status: 401,
|
||||
body: { detail: "invalid email or password" },
|
||||
}),
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
const error = await client.login("owner@example.org", "wrong").catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(AuthError);
|
||||
expect((error as AuthError).status).toBe(401);
|
||||
expect((error as AuthError).message).toBe("invalid email or password");
|
||||
});
|
||||
|
||||
it("logout POSTs /api/auth/logout with the CSRF header", async () => {
|
||||
const { headersOf } = mutationCapture();
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/auth/logout": (_url: string, init?: RequestInit) => {
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
return { status: 200, body: { ok: true } };
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
await expect(client.logout()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("getSession maps 200 → UserProfile; 401 → null (never a throw)", async () => {
|
||||
const ok = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
"http://127.0.0.1:8000/api/auth/me": () => ({
|
||||
status: 200,
|
||||
body: backendUser,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect((await ok.getSession())?.user_id).toBe("abcd2345");
|
||||
|
||||
const unauthed = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
"http://127.0.0.1:8000/api/auth/me": () => ({
|
||||
status: 401,
|
||||
body: { detail: "authentication required" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(await unauthed.getSession()).toBeNull();
|
||||
});
|
||||
|
||||
it("admin hooks hit the protected routes with CSRF on mutations", async () => {
|
||||
const { headersOf } = mutationCapture();
|
||||
const routes: Record<string, RouteHandler> = {
|
||||
// Registered BEFORE the collection route: the deactivate path
|
||||
// startswith-matches "/api/admin/users" too.
|
||||
"http://127.0.0.1:8000/api/admin/users/abcd2345/deactivate": (
|
||||
_url: string,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
return { status: 200, body: { ...backendUser, active: false } };
|
||||
},
|
||||
"http://127.0.0.1:8000/api/admin/users": (url: string, init?: RequestInit) => {
|
||||
if (url.endsWith("/api/admin/users") && init?.method !== "POST") {
|
||||
return { status: 200, body: [backendUser] };
|
||||
}
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
...backendUser,
|
||||
user_id: "newaaa01",
|
||||
email: "second@example.org",
|
||||
role: "user",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
|
||||
const users = await client.listUsers();
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0]?.user_id).toBe("abcd2345");
|
||||
|
||||
const created = await client.createUser({
|
||||
email: "second@example.org",
|
||||
password: "not-a-real-secret",
|
||||
role: "user",
|
||||
});
|
||||
expect(created.user_id).toBe("newaaa01");
|
||||
expect(created.role).toBe("user");
|
||||
|
||||
const deactivated = await client.deactivateUser("abcd2345");
|
||||
expect(deactivated.active).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user