20 Commits

Author SHA1 Message Date
jonathan 669584cdb4 Merge pull request 'Phase 4 slice 3 - hosted build profile + SSE-safe HAProxy front template + deploy wiring (REQ-84/85 legs)' (#34) from po/phase4-host-live into milestone/v0.3-hosted
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m18s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m12s
Reviewed-on: #34
2026-10-31 21:24:23 +00:00
coreci-researchx-po 36d428f569 phase4 deploy: hosted build args for the frontend image + /api/config 401 auth smoke (T-4.3/T-4.7(1) host leg)
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m21s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m15s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 1m17s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 2m14s
2026-10-31 20:44:15 +00:00
coreci-researchx-po 7598b1707c phase4 HAProxy front config as documentation (T-4.5, RESEARCH-v03 s9; G-104 rate limit template; D-019 notes)
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m18s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m11s
2026-10-31 20:44:10 +00:00
coreci-researchx-po ce9d1baecf phase4 host-live build profile: ARG/ENV NEXT_PUBLIC_* + hosted build assert (T-4.3, RESEARCH-v03 s10)
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m13s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m18s
2026-10-31 20:44:09 +00:00
jonathan c31b61be7f Merge pull request 'Phase 4 slice 2: two-service release deploy (backend + frontend) on the v0.3 line' (#33) from po/phase4-deploy-two-service into milestone/v0.3-hosted
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m20s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m8s
Reviewed-on: #33
2026-10-31 08:28:27 +00:00
jonathan 79d83bd188 Merge branch 'milestone/v0.3-hosted' into po/phase4-deploy-two-service
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m12s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m15s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 1m13s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 2m8s
2026-10-31 07:36:11 +00:00
jonathan ad404b6659 Merge pull request 'Phase 4 slice 1: backend release image on the v0.3 line (single worker, non-root) + the phase-4 gate ported onto it' (#32) from po/phase4-backend-image into milestone/v0.3-hosted
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m14s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m6s
Reviewed-on: #32
2026-10-31 07:35:48 +00:00
coreci-researchx-po 690925c6d1 ci: port the phase-4 gate onto the two-service branch so the deploy script is gated before it merges
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m18s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m9s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 1m15s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 2m10s
2026-10-31 07:15:11 +00:00
coreci-researchx-po b964c60e72 fix(backend): serve the production entrypoint, not the test factory
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m13s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m58s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 1m13s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 1m59s
The image's CMD ran researchx.api.app:app - the module-global built by
create_app() with no arguments, which serves FakeModelAdapter and
cwd-based storage. Hosting it would have shipped fakes on the public
URL while /healthz answered 200 (P-3 closure; RESEARCH-v03 S8,
PLAN T-4.1). Now: researchx.api.main:app with --proxy-headers, so the
TLS front's forwarded headers are honoured.

Also pins uid/gid 10001 for the non-root user, so the host-side volume
chown in the deploy script is deterministic.
2026-10-31 03:42:47 +00:00
coreci-researchx-po 593be870eb fix(release): own the backend data dir for the non-root container user (phase 4 slice 2, REQ-83) 2026-10-31 03:00:22 +00:00
coreci-researchx-po 8afbf1cd83 release: deploy two services (backend + frontend) - phase 4 slice 2 (REQ-83) 2026-10-31 01:17:32 +00:00
coreci-researchx-po 1eebe94beb ci: port the phase-4 gate onto the v0.3 line and extend its pull_request trigger to the milestone branch
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 59s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m37s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 56s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 1m59s
2026-10-30 23:52:48 +00:00
coreci-researchx-po 6c85ad2824 build(backend): keep secrets, caches and the local data dir out of the build context
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 57s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m40s
2026-10-30 23:52:48 +00:00
coreci-researchx-po b72f16a944 build(backend): the release image for the v0.3 line - single worker, non-root, data dir + /healthz (REQ-83) 2026-10-30 23:52:42 +00:00
coreci fd3d6c2e03 docs(P03): phase 3 shipped — checkpoint advances to phase 4
---ci---
phase: 3
milestone: v0.3
status: complete
requirements:
  covered: [REQ-75, REQ-76, REQ-77, REQ-78, REQ-79, REQ-80, REQ-81, REQ-82]
  partial: []
---/ci---

Ship record: PR #19 squash-merged phase/03-live-surfaces →
milestone/v0.3-hosted (b485fc0), tag v0.3.3, phase branch deleted
(local+remote), Gitea release v0.3.3 created. Gates green at the tag
(backend 314 passed + ruff + mypy; frontend 164 passed + tsc + build
+ drift). REQUIREMENTS traceability REQ-75..82 → complete; ROADMAP
P3 → complete, P4 (hosting) → next.
2026-09-30 14:23:14 +00:00
coreci-bot b485fc0817 feat(P03): live surfaces — read-models, Reading Room fidelity, standalone client (#19)
Squash-merge of phase/03-live-surfaces into milestone/v0.3-hosted. REQ-75..82 covered: disk read-models, report.json/evidence.json artifacts with marker normalization, enriched report endpoint, refine (per-user chain, honest 503), profile routes, standalone live client (D-112 delegation ends), G-109 honest inbox, Reading Room parity, P-12 fabrication dead. Verify PASS-WITH-FIXES. 314 backend + 164 frontend tests. Ships as v0.3.3.
2026-09-30 14:22:54 +00:00
coreci ccb373dee1 docs(P02): phase 2 shipped — checkpoint advances to phase 3
---ci---
phase: 2
milestone: v0.3
status: complete
requirements:
  covered: [REQ-72, REQ-73, REQ-74]
  partial: []
---/ci---

Ship record: PR #18 squash-merged phase/02-per-user-settings-keys →
milestone/v0.3-hosted (ee26ff1), tag v0.3.2, phase branch deleted
(local+remote), Gitea release v0.3.2 created. Gates green at the tag
(backend 264 passed + ruff + mypy; frontend 131 passed + tsc + build
+ drift). REQUIREMENTS traceability REQ-72..74 → complete; ROADMAP
P2 → complete, P3 → next.
2026-09-30 10:31:45 +00:00
coreci-bot ee26ff1658 feat(P02): per-user settings + model keys (#18)
Squash-merge of phase/02-per-user-settings-keys into milestone/v0.3-hosted. REQ-72/73/74 covered: encrypted key store (AES-256-GCM, AAD, masked-only, honest degradation), per-user settings, ModelAdapterFactory per-run chain (user→server→keyless), config rework (factory probe, owner-keyed counters, P-1/P-2/P-13 closures), production main entrypoint (P-3 backend half). Verify PASS-WITH-FIXES (P0 null-poisoning fixed). 264 backend + 131 frontend tests. Ships as v0.3.2.
2026-09-30 10:31:24 +00:00
coreci b8b0b68f1b docs(P01): phase 1 shipped — checkpoint advances to phase 2
---ci---
phase: 1
milestone: v0.3
status: complete
requirements:
  covered: [REQ-69, REQ-70, REQ-71]
  partial: []
---/ci---

Ship record: PR #17 squash-merged phase/01-auth-sessions-scoping →
milestone/v0.3-hosted (2a61433), tag v0.3.1, phase branch deleted
(local+remote), Gitea release v0.3.1 created. Gates green at the tag
(backend 184 passed + ruff + mypy; frontend 119 passed + tsc + build
+ drift gate). REQUIREMENTS traceability REQ-69..71 → complete;
ROADMAP P1 → complete, P2 → next.
2026-09-29 21:36:50 +00:00
coreci-bot 2a61433e6c feat(P01): auth + sessions + user scoping (#17)
Squash-merge of phase/01-auth-sessions-scoping into milestone/v0.3-hosted. REQ-69/70/71 covered. Auth package (argon2id users, server-side sessions, guard+CSRF, admin routes), user-scoped storage partitioning, owner-keyed registry, restart sweep (G-101), registry pruning, frontend session surface (login, gate, user menu, ApiClient extensions), openapi snapshot regen + drift gate. Security: P0 register race + P1 corrupt-store fail-closed fixed in-phase. Verify: PASS 4/4 layers, 184+119 tests. Ships as v0.3.1.
2026-09-29 21:36:31 +00:00
85 changed files with 14939 additions and 399 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
{
"phase": 0,
"phase": 3,
"stage": "complete",
"milestone": "v0.3",
"phase_role": "pre_execution",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-09-29T17:25:00Z"
"updated_at": "2026-09-29T23:20:00Z"
}
+4 -4
View File
@@ -161,8 +161,8 @@
| REQ-25, REQ-26, REQ-33 (API, client, tooling) | v0.2 | P3 | complete (v0.2.3) |
| REQ-17, REQ-32, REQ-34 (adversarial, E2E, round-trip) | v0.2 | P4 | complete (v0.2.4) |
| All REQ-01..34 (milestone gate) | v0.2 | P5 | complete (v0.2.5 — **owner approval recorded 2026-09-27**, G-009 ratified; milestone merged to main) |
| REQ-69..71 (auth, sessions, scoping) | v0.3 | P1 | pending |
| REQ-72..74 (settings, per-user keys, resolution chain) | v0.3 | P2 | pending |
| REQ-76..82 (live surfaces, user-scoped) | v0.3 | P3 | pending |
| REQ-75 (delegation-end half) + REQ-83..85 (hosting; live default hosted) | v0.3 | P3/P4 (REQ-75 split per G-103: delegation-end P3, hosted-default P4) | pending |
| REQ-69..71 (auth, sessions, scoping) | v0.3 | P1 | complete (v0.3.1; security review P0 register race + P1 corrupt-store fail-closed fixed in-phase; verify PASS 4/4) |
| REQ-72..74 (settings, per-user keys, resolution chain) | v0.3 | P2 | complete (v0.3.2; P0 null-poisoning fixed in verify; P-1/P-2/P-13 closed; production main entrypoint P-3 backend half) |
| REQ-76..82 (live surfaces, user-scoped) | v0.3 | P3 | complete (v0.3.3; D-112 standalone client, G-109 honest inbox, P-8 full closure, P-12 dead; verify PASS-WITH-FIXES) |
| REQ-75 (delegation-end half) + REQ-83..85 (hosting; live default hosted) | v0.3 | P3/P4 (REQ-75 split per G-103: delegation-end P3 complete, hosted-default P4) | REQ-75 complete in P3 |
| REQ-35..51 | v2 backlog (post-MVP milestones) | — | pending |
+3 -3
View File
@@ -145,21 +145,21 @@ Scope (owner session 2026-09-29): one milestone combining the G-009-ratified liv
- **Requirements covered**: REQ-69, REQ-70, REQ-71
- **Success criteria**: unauthenticated API access returns 401; two users cannot see each other's runs; admin can create/deactivate accounts; existing engine tests green under an authenticated harness
- **Depends on**: 0
- **Status**: pending
- **Status**: complete (PR #17 squash-merged to milestone/v0.3-hosted 2a61433, tag v0.3.1, release HTTP 201, phase branch deleted; verify PASS 4/4 — 184 backend + 119 frontend tests; security P0 register race + P1 corrupt-store fail-closed fixed in-phase; D-116 rate-limit decision recorded)
### Phase 2 — Per-User Settings + Model Keys
- **Description**: Encrypted per-user key store (KEK from env-file secrets scope), per-user settings persistence (PUT/GET /api/config), per-run credential resolution chain (user key → server key → keyless localhost; REQ-74) with probe per resolved key, settings UI live-bound with masked keys.
- **Requirements covered**: REQ-72, REQ-73, REQ-74
- **Success criteria**: a keyed user gets fully real inference end-to-end; keys never appear in API responses (masked only), logs, or traces; resolution tier surfaced in config status; zero-key tests stay green via REQ-31 fakes
- **Depends on**: 1
- **Status**: pending
- **Status**: complete (PR #18 squash-merged to milestone/v0.3-hosted ee26ff1, tag v0.3.2, release HTTP 201, phase branch deleted; verify PASS-WITH-FIXES — P0 settings null-poisoning fixed in-phase; 264 backend + 131 frontend tests)
### Phase 3 — Live Surfaces (user-scoped)
- **Description**: Library read-model (GET /api/library), connections + graph (GET /api/library/connections, GET /api/graph), task inbox (GET /api/tasks), suggestions (GET /api/suggestions), refine (POST /api/refine/*, real planner calls), profile (GET/PUT /api/profile), live Reading Room fidelity (structured report, anchored citations, ledger panel). G-002 boundary lifted; frontend live swaps with zero component rewrites (REQ-54).
- **Requirements covered**: REQ-76, REQ-77, REQ-78, REQ-79, REQ-80, REQ-81, REQ-82
- **Success criteria**: every prototype surface works against real user data in live mode; suggestions/refine driven by real model calls; Reading Room parity with mock; ≤3-click path still green
- **Depends on**: 2
- **Status**: pending
- **Status**: complete (PR #19 squash-merged to milestone/v0.3-hosted b485fc0, tag v0.3.3, release HTTP 201, phase branch deleted; verify PASS-WITH-FIXES — CSRF test pin P0 fixed; G-108 valve not needed; 314 backend + 164 frontend tests)
### Phase 4 — Hosting + Real-Inference Default
- **Description**: Backend Dockerfile (single worker, non-root), two-service deployment via deploy.sh extension, SSE-safe HAProxy path routing (/api → backend, streaming timeouts), hosted instance runs live mode by default (mock demoted to dev/test fixture), auth guard verified through the public URL.
+97 -20
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
# ResearchX release deploy (ciagent release system)
#
# Builds the frontend at a given version tag on the Docker host and
# runs it as a container. Idempotent: re-deploying a version replaces
# the running container, keeping the previous one as rollback.
# Builds the frontend and the backend at a given version tag on the
# Docker host and runs them as two containers. Idempotent: re-deploying
# a version replaces the running containers, keeping the previous ones
# as rollback.
#
# Usage: bash .ciagent/release/deploy.sh <version> e.g. v0.1.4
#
@@ -21,8 +22,14 @@ IMAGE_NAME="researchx"
CONTAINER_NAME="researchx"
CONTAINER_PREV="researchx-prev"
HOST_PORT="3000"
IMAGE_BACKEND="researchx-backend"
CONTAINER_BACKEND="researchx-backend"
CONTAINER_BACKEND_PREV="researchx-backend-prev"
HOST_PORT_BACKEND="8000"
BACKEND_DATA_DIR="/var/lib/researchx"
REMOTE_BASE="/opt/researchx"
HEALTH_PATH="/ask"
BACKEND_HEALTH_PATH="/healthz"
HEALTH_RETRIES=15
HEALTH_INTERVAL=2
# -----------------------------------------------------------------------
@@ -38,6 +45,7 @@ VERSION="$1"
# ---- dependency checks --------------------------------------------------
command -v ssh >/dev/null || die "ssh not found" 2
command -v uv >/dev/null || die "uv not found (backend gates need it: pip install uv)" 2
# ---- secrets (env file ONLY) -------------------------------------------
[ -f "$SECRETS_FILE" ] || die "secrets file missing: $SECRETS_FILE" 2
@@ -156,37 +164,75 @@ if [ "$(git -C "$REPO_ROOT" rev-parse HEAD)" != "$TAG_COMMIT" ]; then
WORKTREE=$(mktemp -d)
git -C "$REPO_ROOT" worktree add --detach "$WORKTREE" "$TAG_COMMIT" >/dev/null
trap 'git -C "$REPO_ROOT" worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
BUILD_DIR="$WORKTREE/frontend"
BUILD_ROOT="$WORKTREE"
else
BUILD_DIR="$REPO_ROOT/frontend"
BUILD_ROOT="$REPO_ROOT"
fi
BUILD_DIR="$BUILD_ROOT/frontend"
BACKEND_DIR="$BUILD_ROOT/backend"
(cd "$BUILD_DIR" && pnpm install --frozen-lockfile >/dev/null 2>&1)
(cd "$BUILD_DIR" && pnpm typecheck >/dev/null) || die "typecheck failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm lint >/dev/null) || die "lint failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm test >/dev/null 2>&1) || die "tests failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm build >/dev/null 2>&1) || die "build failed at $VERSION" 3
log "gates passed: typecheck, lint, test, build"
log "frontend gates passed: typecheck, lint, test, build"
cd "$REPO_ROOT"
# Backend gates: the repo's own commands, same as the CI backend job
# (.gitea/workflows/ci.yml), run from the committed lockfile.
(cd "$BACKEND_DIR" && uv sync --frozen >/dev/null 2>&1) || die "backend dependency sync failed at $VERSION" 3
(cd "$BACKEND_DIR" && uv run ruff check src tests >/dev/null) || die "backend ruff failed at $VERSION" 3
(cd "$BACKEND_DIR" && uv run mypy src >/dev/null) || die "backend mypy failed at $VERSION" 3
(cd "$BACKEND_DIR" && uv run pytest >/dev/null) || die "backend pytest failed at $VERSION" 3
log "backend gates passed: ruff, mypy, pytest"
# ---- remote host prep ----------------------------------------------------
log "connecting to $DEPLOY_USER@${DEPLOY_HOST%%:*} and preparing $REMOTE_BASE"
SSH "mkdir -p '$REMOTE_BASE/releases/$BASE_VERSION'" || die "cannot reach host or create remote dir" 4
SSH "command -v docker >/dev/null" || die "docker not found on host" 4
# ---- sync source ---------------------------------------------------------
log "syncing source to host ($REMOTE_BASE/releases/$BASE_VERSION) via $SYNC_TRANSPORT"
sync_source "$BUILD_DIR" "$REMOTE_BASE/releases/$BASE_VERSION" \
|| die "source sync failed" 4
RELEASE_DIR="$REMOTE_BASE/releases/$BASE_VERSION"
log "syncing source to host ($RELEASE_DIR) via $SYNC_TRANSPORT"
sync_source "$BUILD_DIR" "$RELEASE_DIR/frontend" || die "frontend source sync failed" 4
sync_source "$BACKEND_DIR" "$RELEASE_DIR/backend" || die "backend source sync failed" 4
# ---- remote build --------------------------------------------------------
log "building docker image $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
SSH "cd '$REMOTE_BASE/releases/$BASE_VERSION' && \
docker build -t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \
|| die "docker build failed on host" 4
log "building docker images $IMAGE_BACKEND:$VERSION and $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
SSH "cd '$RELEASE_DIR/backend' && \
docker build -t '$IMAGE_BACKEND:$VERSION' -t '$IMAGE_BACKEND:latest' ." \
|| die "backend docker build failed on host" 4
SSH "cd '$RELEASE_DIR/frontend' && \
docker build \
--build-arg NEXT_PUBLIC_RESEARCHX_API_MODE=live \
--build-arg NEXT_PUBLIC_API_BASE_URL=\"\" \
-t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \
|| die "frontend docker build failed on host" 4
# ---- swap container ------------------------------------------------------
log "swapping container (previous kept as $CONTAINER_PREV for rollback)"
log "swapping backend container (previous kept as $CONTAINER_BACKEND_PREV for rollback)"
SSH "
docker rm -f '$CONTAINER_BACKEND_PREV' 2>/dev/null || true
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_BACKEND'; then
docker rename '$CONTAINER_BACKEND' '$CONTAINER_BACKEND_PREV'
docker stop '$CONTAINER_BACKEND_PREV' >/dev/null 2>&1 || true
fi
mkdir -p '$BACKEND_DATA_DIR'
# The container runs as the image's non-root user and a bind mount
# SHADOWS the image's own (correctly owned) /var/lib/researchx, so a
# root-owned host dir makes every write to the data root (users.json,
# sessions.json, keys/) fail with EACCES while /healthz still answers
# 200. Take the owner from the image itself.
chown -R \$(docker run --rm --entrypoint stat '$IMAGE_BACKEND:$VERSION' -c '%u:%g' /var/lib/researchx) '$BACKEND_DATA_DIR'
docker run -d --name '$CONTAINER_BACKEND' --restart unless-stopped \
-p '$HOST_PORT_BACKEND:8000' \
-v '$BACKEND_DATA_DIR:$BACKEND_DATA_DIR' \
-e RESEARCHX_DATA_DIR='$BACKEND_DATA_DIR' \
'$IMAGE_BACKEND:$VERSION'
" || die "backend docker run failed" 5
log "swapping frontend container (previous kept as $CONTAINER_PREV for rollback)"
SSH "
docker rm -f '$CONTAINER_PREV' 2>/dev/null || true
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_NAME'; then
@@ -195,19 +241,50 @@ SSH "
fi
docker run -d --name '$CONTAINER_NAME' --restart unless-stopped \
-p '$HOST_PORT:3000' '$IMAGE_NAME:$VERSION'
" || die "docker run failed" 5
" || die "frontend docker run failed" 5
# ---- health check ---------------------------------------------------------
log "health-checking http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
log "health-checking backend http://$DEPLOY_HOST:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH (retries: $HEALTH_RETRIES)"
i=0
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH'"; then
log "backend healthy after $((i + 1)) check(s)"
break
fi
i=$((i + 1))
sleep "$HEALTH_INTERVAL"
done
[ "$i" -lt "$HEALTH_RETRIES" ] || die "backend container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
# The health check above is a GET: it proves the app answers, not that it
# can persist. Probe the write path the auth/key stores use, so a
# mis-owned data root fails the deploy instead of the first signup.
log "checking the backend can write its data root"
SSH "docker exec '$CONTAINER_BACKEND' sh -c 'touch \"\$RESEARCHX_DATA_DIR/.write-probe\" && rm -f \"\$RESEARCHX_DATA_DIR/.write-probe\"'" \
|| die "backend data root is not writable by the container user" 5
# Auth smoke (RESEARCH-v03.md section 10 pitfall; PLAN.md T-4.7(1) host-side
# leg): the auth-guarded surface answers 401 unauthenticated. This proves
# the guarded surface is up through the deployed backend -- it does NOT
# discriminate the serving entrypoint (both create_app shapes default
# auth_enabled=True; the entrypoint is pinned by the Dockerfile CMD + the
# build-stage assert). The live-mode tier marker (an authenticated
# /api/config) rides the staged smoke suite, owner directive (D-019).
log "checking GET /api/config answers 401 unauthenticated"
AUTH_CODE=$(SSH "curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:$HOST_PORT_BACKEND/api/config'")
[ "$AUTH_CODE" = "401" ] || die "GET /api/config answered '$AUTH_CODE' unauthenticated, expected 401" 5
log "health-checking frontend http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
i=0
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT$HEALTH_PATH'"; then
log "healthy after $((i + 1)) check(s)"
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (container $CONTAINER_NAME)"
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker start $CONTAINER_NAME'"
log "frontend healthy after $((i + 1)) check(s)"
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (containers $CONTAINER_NAME, $CONTAINER_BACKEND)"
log "backend: http://${DEPLOY_HOST%%:*}:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH (container $CONTAINER_BACKEND)"
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME $CONTAINER_BACKEND; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker rename $CONTAINER_BACKEND_PREV $CONTAINER_BACKEND; docker start $CONTAINER_NAME $CONTAINER_BACKEND'"
exit 0
fi
i=$((i + 1))
sleep "$HEALTH_INTERVAL"
done
die "container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
die "frontend container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
+59
View File
@@ -0,0 +1,59 @@
# research.coreci.dev — HAProxy front config for phase 4 (REQ-84, G-104)
#
# DOCUMENTATION + idempotent template (PLAN.md T-4.5, RESEARCH-v03.md §9):
# the host already runs HAProxy 2.8 with the TLS frontend for
# research.coreci.dev. This file is the reviewed shape to fold into that
# front. It is applied to the live host ONLY on owner deploy directive
# (D-019) — never autonomously from a deploy run. The host's live config
# is not readable from the authoring seat, so `timeout client` /
# `defaults` values below are stated as requirements, not as the
# host's current numbers.
# ---- requirements the host's defaults must already satisfy ----------------
# timeout client 3600s # MUST exceed the 15s sse-starlette ping
# # interval (RESEARCH-v03.md §9) or idle SSE
# # streams are cut mid-run.
# keep-alive default ON # keep http-keep-alive; the SSE stream rides it.
# ---- frontend routes -------------------------------------------------------
frontend https-in
acl is_api path_beg /api
acl is_health path /healthz
use_backend backend_api if is_api or is_health
default_backend frontend_next
# G-104 per-IP login rate limit (PLAN.md T-4.5 scope): stick-table on
# /api/auth/*. Threshold below is a STARTING VALUE for the
# security-engineer review, not a measured traffic figure — rate
# before applying. 30 auth requests / 60s / IP.
acl is_auth path_beg /api/auth
stick-table type ip size 100k expire 60s store http_req_rate(60s)
http-request track-sc0 src if is_auth
http-request deny deny_status 429 if is_auth { sc_http_req_rate(0) gt 30 }
# ---- backend: the API container --------------------------------------------
# SSE hazards (RESEARCH-v03.md §9): HAProxy 2.x does NOT buffer response
# bodies in HTTP mode by default, so text/event-stream streams natively.
# The three things that would break it are deliberately absent here:
# - NO `option http-buffer-response` on this backend
# - NO compression (gzip) on this backend — gzip buffers
# text/event-stream; the "classic" silent SSE killer
# - keep the sse-starlette ping (15s) untouched
backend backend_api
option httpchk GET /healthz
default-server inter 5s fall 3 rise 2
server api1 127.0.0.1:8000 check
timeout server 3600s
timeout tunnel 3600s
# ---- backend: the Next.js container ----------------------------------------
backend frontend_next
timeout server 300s
server next1 127.0.0.1:3000 check
# ---- health contract -------------------------------------------------------
# GET /healthz -> 200 {"status":"ok"} (public router, outside /api per
# D-103; the only unauthenticated route — T-4.8).
# The API container must publish on 127.0.0.1:8000 (loopback only: the
# public surface is the TLS front, so the backend is never directly
# reachable from outside the host).
+162
View File
@@ -0,0 +1,162 @@
# .gitea/workflows/ci.yml - ResearchX phase-4 CI gate (researchx#24).
#
# Ported to the v0.3 line (milestone/v0.3-hosted): phase 4 ships on the
# milestone branch, and the pull_request trigger is extended to it so a
# phase-4 pull request gets a gate run there. The file itself is the same
# gate that ran green on main (researchx#24).
#
# This file IS the gate. The pre-code clause (coreci/company#105, c14057):
# a code phase opens only when the product repo's gating workflow file has
# dispatched at least one run on `main` and every job in that file is green
# in that run. Until this file exists, coreci/researchx has no workflow and
# no run at all - that is the case the clause was amended for (researchx#24,
# c14093). A half-green pipeline does not open the phase either.
#
# Two jobs:
# backend - uv + Python 3.12: ruff, mypy, pytest
# frontend - Node 22 + pnpm 12.6.0: typecheck, lint, test, build
#
# Runner label: `ubuntu-latest` - the org CI fleet's published label whose
# image is docker.gitea.com/runner-images:ubuntu-latest (label map read at
# source: coreci/nextcraft deploy/ci/docker-compose.yaml + deploy/ci/README.md,
# R-3). The previous string `employee-runner` was a fleet NAME, not a label:
# the employee fleet's labels are `employee-py` and `employee-coder` (read
# from a live job log of this seat's own home), and the CI fleet's are node /
# ubuntu-latest / python / release - no runner carried the old label, so a
# merged file asking for it could still buy no run.
#
# Toolchain: measured, not assumed. From the runner's own logs (job 56393,
# coreci/nova-platform, 2026-10-26T23:17Z, and job 57005, coreci/inboxos-v1,
# 2026-10-27T03:01Z): the image carries python 3.12.3, pip, git, node (the
# actions run in it), pypi REACHABLE and github REACHABLE; actions/checkout@v4,
# actions/setup-python@v5 and actions/setup-go@v5 all run inside it. It does
# NOT carry uv or pnpm, so this file installs them: uv from pypi (pip), pnpm
# 12.6.0 through corepack - the repo's own documented way (README command map).
# The gates are the repo's documented commands: backend `uv run ruff check src
# tests`, `uv run mypy src`, `uv run pytest`; frontend `pnpm typecheck`,
# `pnpm lint`, `pnpm test`, `pnpm build`. The frontend lane mirrors the
# release image's own recipe (frontend/Dockerfile: node:22-alpine, corepack,
# pnpm install --frozen-lockfile, pnpm build), so the gate tests what the
# product ships.
#
# Owner of a red leg (the clause asks for the owner of the fix next to the
# owner of the product line): toolchain / image / dispatch problems ->
# coreci-sre (employee-runner-fleet + gitea-instance); product-line problems
# -> coreci-researchx-po. Approval for this file rides the merge request
# (merge = approval, constitution v1.3).
name: ci
on:
push:
pull_request:
branches: ["main", "milestone/v0.3-hosted"]
workflow_dispatch:
jobs:
backend:
name: "backend (uv, Python 3.12: ruff/mypy/pytest)"
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
# uv is not in the CI image; pypi is reachable from it (measured). The
# install is a toolchain leg, not a product gate: the gates still run
# from the repo's own committed lockfile (backend/uv.lock).
- name: Install uv (from pypi)
run: |
set -eu
python -m pip install --upgrade uv
uv --version
- name: Install dependencies (uv, from the committed lockfile)
working-directory: backend
run: uv sync --frozen
- name: Lint (ruff)
working-directory: backend
run: uv run ruff check src tests
- name: Typecheck (mypy)
working-directory: backend
run: uv run mypy src
- name: Test (pytest)
working-directory: backend
run: uv run pytest
frontend:
name: "frontend (Next.js on pnpm: typecheck/lint/test/build)"
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v4
# Node 22 to mirror the release image (frontend/Dockerfile: node:22).
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Setup Python 3.12 (for the real-stack e2e bridge)
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv (from pypi)
run: |
set -eu
python -m pip install --upgrade uv
# pnpm 12.6.0 is pinned by frontend/package.json `packageManager`;
# corepack reads it from there. If the image carries no corepack, npm
# installs the same pinned version.
- name: pnpm 12.6.0 (corepack, pinned by frontend/package.json)
working-directory: frontend
run: |
set -eu
node --version
corepack enable pnpm 2>/dev/null || true
if ! pnpm --version >/dev/null 2>&1; then
npm i -g pnpm@12.6.0
fi
test "$(pnpm --version)" = "12.6.0"
- name: Install dependencies (from the committed lockfile)
working-directory: frontend
run: pnpm install --frozen-lockfile
# frontend/src/lib/api/e2e-real-stack.test.ts drives the real FastAPI
# stack: it spawns `uv run python -m researchx.api.dev_bridge` from
# ../backend (loopback :8000, fakes only, zero API keys). Without a
# synced backend venv that test cannot come up, so the lane provisions
# it. Provisioning only - the backend gates stay in the backend job.
- name: Backend venv for the real-stack e2e bridge
working-directory: backend
run: uv sync --frozen
- name: Typecheck
working-directory: frontend
run: pnpm typecheck
- name: Lint
working-directory: frontend
run: pnpm lint
- name: Test
working-directory: frontend
run: pnpm test
- name: Build
working-directory: frontend
env:
NEXT_TELEMETRY_DISABLED: "1"
run: pnpm build
+17
View File
@@ -0,0 +1,17 @@
# Build-context hygiene for the backend image (mirrors frontend/.dockerignore).
.venv
__pycache__
*.pyc
*.pyo
.pytest_cache
.mypy_cache
.ruff_cache
.git
.env
.env.*
!.env.example
var
tests
Dockerfile
.dockerignore
README.md
+2 -1
View File
@@ -2,4 +2,5 @@
__pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.mypy_cache/
var/
+58
View File
@@ -0,0 +1,58 @@
# ResearchX backend — production release image (ciagent release system)
# Multi-stage: deps -> runtime. FastAPI + uvicorn, single worker, non-root.
# Built ON the Docker host from the synced source (no local docker required),
# mirroring frontend/Dockerfile: the build context is backend/.
#
# Contracts this image holds (REQ-83, v0.3 line):
# - one uvicorn worker — the run registry lives in process memory
# - non-root user "researchx" — house recipe, same as frontend/Dockerfile
# - the mutable data root is RESEARCHX_DATA_DIR (default /var/lib/researchx);
# that is the variable researchx.config.paths.data_root() reads (D-107),
# so the volume mount point is the directory the app actually writes
# - /healthz is the public health surface (outside /api, never auth-wrapped,
# D-103) — the container healthcheck below uses it
# - the serving entrypoint is researchx.api.main (the PRODUCTION wiring:
# auth_enabled=True and cookie_secure=True hardwired, the live model
# factory reading per-user -> server -> keyless credentials from the
# runtime env file). researchx.api.app's module-global is the TEST/DEV
# factory — it serves FakeModelAdapter and cwd-based storage, so
# hosting it would silently ship fakes (P-3 closure, RESEARCH-v03 §8).
# - uid/gid 10001 is fixed so the host-side volume chown is deterministic
# (the deploy script reads the ownership from this image)
# ---- deps ----
FROM python:3.12-slim AS deps
# uv is the repo's package manager (backend/uv.lock). The repo pins no uv
# version, so this install is bounded to a major range; pin it exactly once
# the deploy host's uv version is known.
RUN pip install --no-cache-dir "uv>=0.8,<2"
WORKDIR /app
ENV UV_PYTHON_DOWNLOADS=never
COPY pyproject.toml uv.lock .python-version ./
COPY src ./src
# --frozen: use uv.lock as written (no lock rewrite). --no-dev: no test/lint
# dependencies in the runtime image. --no-editable: copy the package into
# site-packages instead of linking the package source tree.
RUN uv sync --frozen --no-dev --no-editable --no-cache --python 3.12
# ---- runtime ----
FROM python:3.12-slim AS runtime
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
RESEARCHX_DATA_DIR=/var/lib/researchx
COPY --from=deps /app/.venv /app/.venv
# Non-root app user (house recipe: frontend/Dockerfile runs as researchx).
RUN addgroup --gid 10001 researchx \
&& adduser --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin researchx \
&& mkdir -p /var/lib/researchx \
&& chown -R researchx:researchx /var/lib/researchx
USER researchx
EXPOSE 8000
# Public health surface (outside /api, never auth-wrapped). python is already
# in the image, so the check needs no extra package.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"]
# Single worker (REQ-83): the run registry lives in process memory.
CMD ["uvicorn", "researchx.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--proxy-headers"]
+2
View File
@@ -9,6 +9,8 @@ dependencies = [
"uvicorn>=0.30,<1",
"sse-starlette>=2.1,<3",
"httpx>=0.27,<1",
"argon2-cffi>=23.1,<26",
"cryptography>=43",
]
[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)
@@ -0,0 +1,163 @@
"""Per-run model adapter factory (T-2.5, REQ-74 — D-110, P-2 closure).
Resolves the per-run credential chain for the CURRENT user:
user key (decrypted per-user key store) -> tier "user"
server env-file OLLAMA_API_KEY -> tier "server"
neither (localhost:11434 fallback) -> tier "keyless"
One adapter instance per ``resolve()`` call — NEVER cached across runs.
The per-instance ``_active_base`` fallback mutation in
OllamaModelAdapter is then per-run by construction, killing the
shared-adapter bug where one user's cloud failure flipped every
subsequent user's run to localhost.
The keyless tier constructs an adapter with no key (no Authorization
header is sent — see ollama.py) and the existing cloud→local fallback
semantics are preserved: a keyless adapter starts on the cloud base
and falls back on failure exactly as before, so localhost:11434
remains the zero-cost path.
Tampered/corrupt key-store envelopes surface as ``KeyStoreError`` (a
typed ``AdapterError``) from ``resolve()`` — the caller decides how
to present the honest error; the factory NEVER silently falls down the
chain on a read failure (that would be a silent tier drop, C3-06).
"""
import hashlib
from researchx.adapters.model import ModelRoles
from researchx.adapters.ollama import OllamaModelAdapter
from researchx.adapters.secrets import SecretsProvider
from researchx.auth.key_store import KEY_SCOPE, KeyStore, KeyStoreError
from researchx.auth.models import UserPublic
from researchx.auth.settings import DEFAULT_PLANNER_MODEL, DEFAULT_WRITER_MODEL, UserSettings
TIER_USER = "user"
TIER_SERVER = "server"
TIER_KEYLESS = "keyless"
class ModelAdapterFactory:
"""Resolve (adapter, tier) for a user's run — per call, never cached."""
def __init__(
self,
secrets: SecretsProvider,
key_store: KeyStore,
*,
planner_model: str = DEFAULT_PLANNER_MODEL,
writer_model: str = DEFAULT_WRITER_MODEL,
cloud_base_url: str | None = None,
local_base_url: str | None = None,
) -> None:
self._secrets = secrets
self._key_store = key_store
self._planner_model = planner_model
self._writer_model = writer_model
self._cloud_base_url = cloud_base_url
self._local_base_url = local_base_url
def _construct(
self, planner: str, writer: str, api_key: str | None
) -> OllamaModelAdapter:
return OllamaModelAdapter(
self._secrets,
planner_model=planner,
writer_model=writer,
cloud_base_url=self._cloud_base_url,
local_base_url=self._local_base_url,
api_key=api_key,
)
async def resolve(
self, user: UserPublic, settings: UserSettings
) -> tuple[OllamaModelAdapter, str]:
"""Resolve the user's credential chain and build ONE adapter.
The adapter's roles honor the user's saved settings
(planner_model/writer_model); an unset settings value keeps the
factory defaults. Raises ``KeyStoreError`` (an AdapterError)
when the user's stored envelope is tampered/corrupt — never a
silent tier drop.
"""
planner = settings.planner_model or self._planner_model
writer = settings.writer_model or self._writer_model
if self._key_store.enabled:
# get_key raises KeyStoreError on tamper — honest error, not
# a silent fall to server/keyless.
user_key = await self._key_store.get_key(user.user_id)
if user_key:
return self._construct(planner, writer, user_key), TIER_USER
server_key = self._secrets.resolve(KEY_SCOPE)
if server_key:
return self._construct(planner, writer, server_key), TIER_SERVER
return self._construct(planner, writer, None), TIER_KEYLESS
async def resolve_status(
self, user: UserPublic, settings: UserSettings
) -> tuple[OllamaModelAdapter, str, str | None]:
"""Status variant for GET /api/config: like resolve() but a
tampered/corrupt key-store read DEGRADES down the chain and
returns the honest error message instead of raising — the
config status must never 500 on a corrupt envelope, and the
tier must never silently claim 'user' when the key cannot be
decrypted. create_run keeps the raising resolve() (a corrupt
key there is an honest 409 failure).
"""
error: str | None = None
planner = settings.planner_model or self._planner_model
writer = settings.writer_model or self._writer_model
if self._key_store.enabled:
try:
user_key = await self._key_store.get_key(user.user_id)
except KeyStoreError as exc:
# Honest degradation: the tier falls to server/keyless
# but the error message is carried alongside — surfaced
# by the config status so the user can see WHY.
user_key = None
error = str(exc)
if user_key:
return self._construct(planner, writer, user_key), TIER_USER, None
server_key = self._secrets.resolve(KEY_SCOPE)
if server_key:
return self._construct(planner, writer, server_key), TIER_SERVER, error
return self._construct(planner, writer, None), TIER_KEYLESS, error
async def key_fingerprint(self, user: UserPublic) -> str:
"""Stable per-(user, tier, key) probe-cache key component.
The raw key is NEVER the fingerprint — a short sha256 of the
effective key (or the tier itself when keyless) identifies the
resolution without exposing material. A key-store read error
yields the server/keyless fingerprint; the probe itself will
surface the error state through the config status.
"""
if self._key_store.enabled:
try:
user_key = await self._key_store.get_key(user.user_id)
except KeyStoreError:
user_key = None
if user_key:
return f"{TIER_USER}:{hashlib.sha256(user_key.encode()).hexdigest()[:12]}"
server_key = self._secrets.resolve(KEY_SCOPE)
if server_key:
return f"{TIER_SERVER}:{hashlib.sha256(server_key.encode()).hexdigest()[:12]}"
return TIER_KEYLESS
def roles_defaults(self) -> ModelRoles:
return ModelRoles(planner=self._planner_model, writer=self._writer_model)
__all__ = [
"TIER_KEYLESS",
"TIER_SERVER",
"TIER_USER",
"ModelAdapterFactory",
]
+17 -6
View File
@@ -3,7 +3,7 @@ from typing import Any
import httpx
from researchx.adapters.model import ModelRoles
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.secrets import SecretsProvider
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage, Usage
_DEFAULT_CLOUD_BASE = "https://ollama.com/v1"
@@ -16,18 +16,22 @@ class OllamaModelAdapter:
def __init__(
self,
secrets: EnvFileSecretsProvider,
secrets: SecretsProvider,
planner_model: str,
writer_model: str,
cloud_base_url: str | None = None,
local_base_url: str | None = None,
api_key: str | None = None,
) -> None:
self._secrets = secrets
self._roles = ModelRoles(planner=planner_model, writer=writer_model)
self._cloud_base = (cloud_base_url or secrets.resolve("OLLAMA_BASE_URL") or _DEFAULT_CLOUD_BASE).rstrip("/")
self._local_base = (local_base_url or _DEFAULT_LOCAL_BASE).rstrip("/")
self._active_base = self._cloud_base
self._api_key = secrets.resolve("OLLAMA_API_KEY")
# Explicit api_key takes precedence over the secrets resolve
# (T-2.5: the per-run factory passes the user's decrypted key);
# the secrets path stays for back-compat (server tier).
self._api_key = api_key if api_key is not None else secrets.resolve("OLLAMA_API_KEY")
self._last_fallback_reason: str | None = None
def _headers(self, base_url: str) -> dict[str, str]:
@@ -99,12 +103,19 @@ class OllamaModelAdapter:
raise AdapterError(f"models listing failed ({type(exc).__name__})") from exc
data: dict[str, Any] = response.json()
return [str(entry["id"]) for entry in data.get("data", [])]
except (AdapterError, httpx.HTTPError):
if self._active_base == self._cloud_base:
except (AdapterError, httpx.HTTPError) as exc:
# Fallback only if there IS a distinct local base to fall
# back to (a caller pinning both bases to one endpoint —
# e.g. a private gateway — must not recurse forever), and
# never leak a raw httpx error: the adapter's outward
# contract is a typed AdapterError.
if self._active_base == self._cloud_base and self._local_base != self._cloud_base:
self._last_fallback_reason = "cloud models listing failed; using local"
self._active_base = self._local_base
return await self.list_models()
raise
if isinstance(exc, AdapterError):
raise
raise AdapterError(f"models listing failed ({type(exc).__name__})") from exc
def roles(self) -> ModelRoles:
return self._roles
@@ -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: ...
+691 -116
View File
@@ -1,9 +1,11 @@
import asyncio
import json
import time
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
@@ -11,11 +13,22 @@ from sse_starlette.sse import EventSourceResponse
from researchx.adapters.capability_probe import probe_model_adapter, supports_probe
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import AdapterError, ChatResult
from researchx.adapters.model_factory import ModelAdapterFactory
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
from researchx.api.read_models import connections, graph, library, list_runs, suggestions, tasks
from researchx.auth.admin_routes import build_admin_router
from researchx.auth.guard import Guard, build_admin_guard, build_dev_guard, build_guard
from researchx.auth.key_store import KEY_SCOPE, KeyStore, KeyStoreError
from researchx.auth.models import _EMAIL_RE, UserPublic
from researchx.auth.routes import build_auth_router
from researchx.auth.sessions import SessionStore
from researchx.auth.settings import SettingsStore, SettingsStoreError, UserSettings
from researchx.auth.users import UserError, 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 +40,12 @@ _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"]
# Config-status probe cache TTL (P-1): in-process, keyed
# (user_id, tier, key_fingerprint) so a key change invalidates.
_PROBE_CACHE_TTL_SECONDS = 60.0
class CreateRunBody(BaseModel):
question: str = Field(min_length=1, max_length=2000)
@@ -39,6 +58,42 @@ class ApproveBody(BaseModel):
plan: dict[str, Any] | None = None
class SettingsBody(BaseModel):
"""PUT /api/config/settings body — partial update (D-111)."""
planner_model: str | None = Field(default=None, min_length=1, max_length=200)
writer_model: str | None = Field(default=None, min_length=1, max_length=200)
methodology_defaults: dict[str, Any] | None = None
appearance: str | None = Field(default=None, max_length=40)
class ModelKeyBody(BaseModel):
"""PUT /api/config/settings/model-key body — the raw key, accepted,
never echoed (REQ-73)."""
key: str = Field(min_length=1, max_length=512)
class KeyStatus(BaseModel):
"""Masked-only key status — the raw key NEVER appears (REQ-73)."""
configured: bool
masked: str | None = None
disabled: bool = False
reason: str | None = None
class ConfigStatus(BaseModel):
resolved_tier: str # user | server | keyless (REQ-74)
key_store: str # enabled | disabled
key_status: KeyStatus
model_adapter: str
search_adapter: str
storage_dir: str
probe: dict[str, Any] | None = None
cost_counters: dict[str, int]
class RunSummary(BaseModel):
id: str
status: str
@@ -59,14 +114,6 @@ class MethodologyCard(BaseModel):
dials: dict[str, Any]
class ConfigStatus(BaseModel):
model_adapter: str
search_adapter: str
storage_dir: str
probe: dict[str, Any] | None = None
cost_counters: dict[str, int]
_STATUS_MAP = {
RunStateName.PLANNING: "planning",
RunStateName.AWAITING_APPROVAL: "awaiting_approval",
@@ -84,11 +131,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 +148,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,27 +257,81 @@ 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,
key_store: KeyStore | None = None,
settings_store: SettingsStore | None = None,
model_factory: ModelAdapterFactory | None = None,
secrets_provider: Any = 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.
Per-user stores (T-2.2/T-2.3): ``key_store`` defaults to a KeyStore
over the data root (disabled when no KEK is configured — honest
degradation); ``settings_store`` likewise. Both are injectable for
tests. ``model_factory`` (T-2.5) resolves the per-user credential
chain user → server → keyless for ``create_run`` and
``GET /api/config``; when absent the fixed ``model_adapter`` is
used (test harness), tiered resolution disabled by construction.
"""
app = FastAPI(title="ResearchX API", version="0.2.0")
# 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=[
ChatResult(content=_CANNED_PLAN, model="fake-planner"),
ChatResult(content=_CANNED_WRITER, model="fake-writer"),
]
)
# The fallback model is constructed ONLY when no factory is wired:
# the factory path (production, T-2.5) resolves a real adapter per
# run and the production entrypoint can therefore never hold a
# FakeModelAdapter in memory (P-3).
model: Any
if model_adapter is not None:
model = model_adapter
elif model_factory is not None:
model = None # every consumer goes through the factory
else:
model = FakeModelAdapter(
canned=[
ChatResult(content=_CANNED_PLAN, model="fake-planner"),
ChatResult(content=_CANNED_WRITER, model="fake-writer"),
]
)
search = search_adapter or FakeSearchAdapter(CORPUS)
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")
# Per-user stores wired from the same root as the auth stores;
# injectable for tests (key_store=None -> disabled, honest reason).
key_store = key_store if key_store is not None else KeyStore(
secrets_provider or _NullSecrets(), auth_root
)
settings_store = settings_store if settings_store is not None else SettingsStore(auth_root)
guard: Guard = build_guard(users, sessions) if auth_enabled else build_dev_guard()
admin_guard = build_admin_guard(guard)
# In-process probe cache keyed (user_id, tier, key_fingerprint)
# with a short TTL (P-1 closure — the probe runs against the
# RESOLVED tier, never stolen from the registry, and a key change
# rotates the fingerprint which invalidates the entry).
probe_cache: dict[tuple[str, str, str], tuple[float, dict[str, Any] | None]] = {}
def _owner_storage(user: UserPublic) -> LocalStorageAdapter:
"""Owner-scoped storage, constructed per request from the auth
dependency (REQ-71): runs/<user_id>/<run_id>/."""
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 +342,142 @@ def create_app(
started_at=state.created_at.isoformat(),
)
@app.get("/api/config")
async def config_status() -> ConfigStatus:
latest_probe = None
for run_id in list(runs._actors)[-1:]:
actor_probe = runs.actor(run_id).state.probe
latest_probe = actor_probe
return ConfigStatus(
model_adapter=type(model).__name__,
search_adapter=type(search).__name__,
storage_dir=str(storage.run_dir("00000000").parent),
probe=latest_probe,
cost_counters=runs.cost_counters,
async def _masked_key_status(user: UserPublic) -> KeyStatus:
"""Masked-only key status — never the raw key (REQ-73)."""
try:
status = await key_store.masked_status(user.user_id)
except KeyStoreError as exc:
# Stored-but-unreadable: surface the error state honestly.
return KeyStatus(
configured=False, masked=None, disabled=not key_store.enabled, reason=str(exc)
)
masked = status.get("masked")
reason = status.get("reason")
return KeyStatus(
configured=bool(status.get("configured")),
masked=str(masked) if masked is not None else None,
disabled=bool(status.get("disabled")),
reason=str(reason) if reason is not None else None,
)
@app.get("/api/methodologies")
# ------------------------------------------------------------------
# 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", response_model=ConfigStatus)
async def config_status(user: Annotated[UserPublic, Depends(guard)]) -> ConfigStatus:
"""Current user's resolved credential tier + status (T-2.6).
P-1 closure: the probe is resolved via the factory for the
CURRENT user — no run creation, no reads of the private
registry dict, correct with zero runs, no cross-user leak.
A corrupt/tampered stored envelope degrades the tier honestly
(server/keyless) and surfaces the error in key_status.reason —
never a 500, never a silent drop.
"""
tier = "keyless"
adapter_name = type(model).__name__ if model is not None else "unknown"
probe: dict[str, Any] | None = None
resolve_error: str | None = None
if model_factory is not None:
settings = await settings_store.get(user.user_id)
adapter, tier, resolve_error = await model_factory.resolve_status(user, settings)
adapter_name = type(adapter).__name__
fingerprint = await model_factory.key_fingerprint(user)
cache_key = (user.user_id, tier, fingerprint)
now = time.monotonic()
cached = probe_cache.get(cache_key)
if cached is not None and now - cached[0] < _PROBE_CACHE_TTL_SECONDS:
probe = cached[1]
elif supports_probe(adapter):
try:
probe_result = await probe_model_adapter(adapter)
probe = probe_result.model_dump(mode="json")
probe_cache[cache_key] = (now, probe)
except AdapterError:
probe = None
probe_cache[cache_key] = (now, None)
key_status = await _masked_key_status(user)
if resolve_error is not None and key_status.reason is None:
# The tier degraded honestly; say WHY (never silently).
key_status = key_status.model_copy(update={"reason": resolve_error})
return ConfigStatus(
resolved_tier=tier,
key_store="enabled" if key_store.enabled else "disabled",
key_status=key_status,
model_adapter=adapter_name,
search_adapter=type(search).__name__,
storage_dir=str(storage_base),
probe=probe,
cost_counters=runs.completed_snapshot(user.user_id),
)
@protected.get("/api/config/settings", response_model=UserSettings)
async def get_settings(user: Annotated[UserPublic, Depends(guard)]) -> UserSettings:
return await settings_store.get(user.user_id)
@protected.put("/api/config/settings", response_model=UserSettings)
async def put_settings(
body: SettingsBody, user: Annotated[UserPublic, Depends(guard)]
) -> UserSettings:
"""Persist a partial settings update (REQ-72); returns the
updated settings. An explicit null field is treated as absent
(partial-merge semantics — the store refuses null-valued
fields, so a null can never poison the on-disk payload)."""
updates: dict[str, Any] = {
key: value
for key, value in body.model_dump(exclude_unset=True).items()
if value is not None
}
try:
current = await settings_store.get(user.user_id)
merged = current.model_copy(update=updates)
return await settings_store.update(user.user_id, merged)
except SettingsStoreError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@protected.put("/api/config/settings/model-key", response_model=KeyStatus)
async def put_model_key(
body: ModelKeyBody, user: Annotated[UserPublic, Depends(guard)]
) -> KeyStatus:
"""Accept the user's raw model key, encrypt it at rest, return
ONLY the masked status (REQ-73 — the raw key never appears in
any response, log, or trace). Without a KEK: honest 409."""
if not key_store.enabled:
# Honest refusal, never a silent no-op.
raise HTTPException(
status_code=409,
detail={
"key_store": "disabled",
"reason": key_store.disabled_reason,
},
)
await key_store.set_key(user.user_id, KEY_SCOPE, body.key)
return await _masked_key_status(user)
@protected.get("/api/config/settings/model-key", response_model=KeyStatus)
async def get_model_key_status(user: Annotated[UserPublic, Depends(guard)]) -> KeyStatus:
"""Masked-only key status (never the raw key)."""
return await _masked_key_status(user)
@protected.get("/api/methodologies")
async def methodologies() -> list[MethodologyCard]:
cards: list[MethodologyCard] = []
for methodology in registry.list():
@@ -202,10 +496,29 @@ 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)
# Per-run credential resolution (T-2.5, REQ-74): one adapter
# instance per run — the _active_base fallback mutation is
# per-instance by construction (P-2 closure).
tier: str | None = None
if model_factory is not None:
settings = await settings_store.get(user.user_id)
try:
run_model, tier = await model_factory.resolve(user, settings)
except KeyStoreError as exc:
# A tampered/corrupt stored key is an honest failure —
# never a silent tier drop and never a wrong-key run.
raise HTTPException(status_code=409, detail=str(exc)) from exc
else:
# No factory: the fixed test-harness adapter (create_app
# guarantees one exists whenever the factory is absent).
run_model = model
try:
run_id, config = make_run_config(
question=body.question,
@@ -213,6 +526,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
@@ -221,43 +535,49 @@ def create_app(
bus = EventBus()
actor = RunActor(
run_config=config,
model=model,
model=run_model,
search=search,
storage=storage,
registry=registry,
event_bus=bus,
)
probe_data: dict[str, Any] | None = None
if supports_probe(model):
# Stamp the owner on the persisted state (REQ-71). The actor
# itself is untouched — engine contracts unchanged.
actor.state.owner_id = user.user_id
actor.state.credential_tier = tier # REQ-74: auditability
if supports_probe(run_model):
try:
probe = await probe_model_adapter(model)
probe_data = probe.model_dump(mode="json")
await actor.attach_probe(probe_data)
probe = await probe_model_adapter(run_model)
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 +585,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 +626,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,35 +648,287 @@ 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]:
"""Disk-read, registry-independent (P-8 full closure, T-3.3):
the run directory under THIS owner is the existence check — no
RunRegistry involvement, correct after a restart with zero
in-memory actors. Pre-artifact DONE runs (and any run whose
report.json was not yet written) fall back to markdown-only
with null report/evidence — never a 500 (D-106: old artifacts
disposable)."""
storage = _owner_storage(user)
if not (storage.run_dir(run_id)).is_dir():
raise HTTPException(status_code=404, detail="run not found")
try:
content = await storage.read_run_artifact(run_id, "report.md")
markdown = await storage.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}
report: dict[str, Any] | None = None
evidence: dict[str, Any] | None = None
try:
parsed = json.loads(await storage.read_run_artifact(run_id, "report.json"))
if isinstance(parsed, dict):
report = parsed
except (AdapterError, json.JSONDecodeError):
pass
try:
parsed = json.loads(await storage.read_run_artifact(run_id, "evidence.json"))
if isinstance(parsed, dict):
evidence = parsed
except (AdapterError, json.JSONDecodeError):
pass
return {"run_id": run_id, "markdown": markdown, "report": report, "evidence": evidence}
@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]:
"""Disk-read, registry-independent (T-3.3): same owner-dir
existence check as the report route."""
storage = _owner_storage(user)
if not (storage.run_dir(run_id)).is_dir():
raise HTTPException(status_code=404, detail="run not found")
try:
content = await storage.read_run_artifact(run_id, "ledger.json")
except AdapterError:
raise HTTPException(status_code=404, detail="ledger not ready")
import json
data: dict[str, Any] = json.loads(content)
return data
# ------------------------------------------------------------- T-3.2/T-3.3
# Live surfaces (REQ-76..79): read-model routes — disk reads only,
# owner-scoped via the guard, NEVER the in-memory RunRegistry
# (D-115). Every route below is correct after a restart with zero
# in-process actors (P-8 full closure, pinned by test).
@protected.get("/api/runs")
async def list_runs_route(user: Annotated[UserPublic, Depends(guard)]) -> list[RunSummary]:
"""All of this owner's runs from disk state files, most recent
first — the registry-independent run list (post-sweep
CANCELLED runs surface cancelled, NOT open)."""
records = list_runs(storage_base, user.user_id)
summaries: list[RunSummary] = []
for record in records:
plan = record.state.get("plan")
summaries.append(
RunSummary(
id=record.run_id,
status=_STATUS_MAP.get(RunStateName(record.status), "failed"),
question=record.question,
methodology=record.methodology_slug,
plan=plan if isinstance(plan, dict) else None,
started_at=record.created_at,
)
)
return summaries
@protected.get("/api/library")
async def library_route(
user: Annotated[UserPublic, Depends(guard)], sort: str = "recent"
) -> dict[str, Any]:
return library(storage_base, user.user_id, sort=sort)
@protected.get("/api/library/connections")
async def connections_route(user: Annotated[UserPublic, Depends(guard)]) -> list[dict[str, Any]]:
return connections(storage_base, user.user_id)
@protected.get("/api/graph")
async def graph_route(user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
return graph(storage_base, user.user_id)
@protected.get("/api/tasks")
async def tasks_route(user: Annotated[UserPublic, Depends(guard)]) -> list[dict[str, str]]:
return tasks(storage_base, user.user_id)
@protected.get("/api/suggestions")
async def suggestions_route(
user: Annotated[UserPublic, Depends(guard)]
) -> list[dict[str, str]]:
return suggestions(storage_base, user.user_id)
# ------------------------------------------------------------- T-3.6
# Refine + profile (REQ-80/81). The refine planner calls ride the
# SAME per-user credential chain as runs (P2 factory); an
# unavailable model is an honest 503, never a hang.
class RefineDraftBody(BaseModel):
draft: str = Field(min_length=1, max_length=2000)
class RefineComposeBody(BaseModel):
draft: str = Field(min_length=1, max_length=2000)
answers: list[str] = Field(default_factory=list, max_length=10)
_REFINE_QUESTIONS_PROMPT = (
"You are a research question refiner. The user drafted a research "
"question. Reply with a JSON object {{\"questions\": [...]}} holding "
"exactly three short clarifying questions that would sharpen the "
"draft. No commentary, JSON only.\nDraft: {draft}"
)
_REFINE_COMPOSE_PROMPT = (
"You are a research question refiner. The user drafted a research "
"question and answered clarifying questions. Compose the refined "
"research question and a short suggested-context paragraph. Reply "
"with a JSON object {{\"question\": \"...\", \"context\": \"...\"}}, "
"JSON only.\nDraft: {draft}\nAnswers: {answers}"
)
def _parse_refine_json(content: str) -> dict[str, Any]:
try:
parsed = json.loads(content)
except json.JSONDecodeError as exc:
raise AdapterError("refine planner returned non-JSON") from exc
if not isinstance(parsed, dict):
raise AdapterError("refine planner returned non-JSON")
return parsed
async def _refine_model(user: UserPublic) -> tuple[Any, str]:
"""Resolve the per-user adapter for a refine call — the same
chain as create_run (user -> server -> keyless), one fresh
adapter instance per call (P-2)."""
if model_factory is not None:
settings = await settings_store.get(user.user_id)
run_model, tier = await model_factory.resolve(user, settings)
return run_model, tier
if model is None: # factory wired but absent — impossible via create_app
raise AdapterError("no model adapter available")
return model, "keyless"
@protected.post("/api/refine/questions")
async def refine_questions(
body: RefineDraftBody, user: Annotated[UserPublic, Depends(guard)]
) -> dict[str, Any]:
draft = body.draft.strip()
if not draft:
raise HTTPException(status_code=422, detail="draft must not be empty")
try:
adapter, _tier = await _refine_model(user)
result = await adapter.chat(
[ModelMessage(role="user", content=_REFINE_QUESTIONS_PROMPT.format(draft=draft))],
model=adapter.roles().planner,
json_mode=True,
)
except AdapterError as exc:
# Honest 503 (T-3.6): the resolved tier is unavailable —
# never a hang, never a silent success.
raise HTTPException(status_code=503, detail=str(exc)) from exc
parsed = _parse_refine_json(result.content)
raw = parsed.get("questions", [])
questions = [str(q).strip() for q in raw if str(q).strip()] if isinstance(raw, list) else []
if not questions:
raise HTTPException(status_code=503, detail="refine planner returned no questions")
return {"questions": questions[:3]}
@protected.post("/api/refine/compose")
async def refine_compose(
body: RefineComposeBody, user: Annotated[UserPublic, Depends(guard)]
) -> dict[str, str]:
draft = body.draft.strip()
if not draft:
raise HTTPException(status_code=422, detail="draft must not be empty")
answers = [str(a).strip() for a in body.answers if str(a).strip()]
try:
adapter, _tier = await _refine_model(user)
result = await adapter.chat(
[
ModelMessage(
role="user",
content=_REFINE_COMPOSE_PROMPT.format(
draft=draft, answers=json.dumps(answers)
),
)
],
model=adapter.roles().planner,
json_mode=True,
)
except AdapterError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
parsed = _parse_refine_json(result.content)
question = str(parsed.get("question", "")).strip()
if not question:
raise HTTPException(status_code=503, detail="refine planner returned no question")
return {"question": question, "context": str(parsed.get("context", "")).strip()}
# ------------------------------------------------------------- profile
class ProfileBody(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=120)
email: str | None = Field(default=None, pattern=_EMAIL_RE, max_length=254)
@protected.get("/api/profile")
async def get_profile(user: Annotated[UserPublic, Depends(guard)]) -> dict[str, str]:
return {
"user_id": user.user_id,
"display_name": user.display_name,
"email": user.email,
}
@protected.put("/api/profile")
async def put_profile(
body: ProfileBody, user: Annotated[UserPublic, Depends(guard)]
) -> dict[str, str]:
"""Update display name and/or email (no deletion, REQ-81).
The email stays unique (casefolded) across other users."""
stored = users.get(user.user_id)
if stored is None:
raise HTTPException(status_code=401, detail="account disabled")
try:
updated = await users.update_profile(
user.user_id,
display_name=body.display_name,
email=body.email,
)
except UserError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {
"user_id": updated.user_id,
"display_name": updated.display_name,
"email": updated.email,
}
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
class _NullSecrets:
"""No-secrets provider: the default key store is disabled unless a
secrets provider is injected (zero-key test harness stays green)."""
def resolve(self, name: str) -> str | None:
return None
# Dev-only module app (README's `uvicorn researchx.api.app:app`).
# Pitfall P-12/§8 item 3: this serves FAKES with default storage —
# the production entrypoint (researchx.api.main, Phase 2) must never
# import this global. Single worker only (in-memory stores by design).
app = create_app()
+6
View File
@@ -64,10 +64,16 @@ class EndlessModelAdapter:
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
# Dev/E2E bridge: auth explicitly OFF (P-5) — a loopback-only,
# disposable harness. Never the production entrypoint; the
# production server (researchx.api.main, Phase 2) never passes
# auth_enabled.
app = create_app(
storage_dir=Path(tmp),
model_adapter=EndlessModelAdapter(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=Path(tmp),
)
@app.on_event("startup")
+87
View File
@@ -0,0 +1,87 @@
"""Production entrypoint (T-2.7 — P-3 backend closure).
``uvicorn researchx.api.main:app`` — the ONLY serving entry the
container ever runs (T-4.1). Distinct from ``researchx.api.app`` (the
test/dev factory, whose module-global serves fakes):
- ``auth_enabled=True`` is HARDWIRED — not a parameter here, never an
env var, never False on any path that serves traffic (REQ-85).
- ``cookie_secure=True`` (E-4 handoff): the cookie is set with Secure
because the public surface sits behind the TLS-fronted proxy.
- Run artifacts + auth stores + key store + settings live under
``RESEARCHX_DATA_DIR`` (container default ``/var/lib/researchx``,
D-107) — never cwd-dependent.
- Secrets come from the runtime env file (``.ciagent/.env.secrets``
by default, overridable via ``RESEARCHX_SECRETS_FILE``; the
container passes its own env-file via ``--env-file``):
``RESEARCHX_KEK`` (keys scope) enables the per-user key store,
``OLLAMA_API_KEY`` the server tier, ``TAVILY_API_KEY`` keyed search.
- The model adapter is ALWAYS the real OllamaModelAdapter, constructed
per run by the ModelAdapterFactory — this module can never serve
FakeModelAdapter (pinned by test_main_entrypoint).
- Search: TavilySearchAdapter when TAVILY_API_KEY is present, else the
documented keyless DuckDuckGo fallback (degraded mode — G-008).
- Single uvicorn worker only (in-memory RunRegistry + session store).
"""
from pathlib import Path
from fastapi import FastAPI
from researchx.adapters.duckduckgo import resolve_search_adapter
from researchx.adapters.model_factory import ModelAdapterFactory
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.api.app import create_app
from researchx.auth.key_store import KeyStore
from researchx.auth.settings import SettingsStore
from researchx.config.paths import data_root
_DEFAULT_SECRETS_PATHS = [
Path("/etc/researchx/env.secrets"), # container convention (runtime bind)
Path.cwd() / ".ciagent" / ".env.secrets", # repo-local dev
Path.cwd().parents[1] / ".ciagent" / ".env.secrets", # tests run from backend/
]
def _secrets_paths() -> list[Path]:
"""Runtime env-file candidates (first existing wins per merge order)."""
import os
override = os.environ.get("RESEARCHX_SECRETS_FILE")
if override:
return [Path(override)]
return [p for p in _DEFAULT_SECRETS_PATHS if p.is_file()] or _DEFAULT_SECRETS_PATHS
def build_app() -> FastAPI:
"""Build the LIVE app: real adapters, real auth, data-root storage.
``auth_enabled`` and ``cookie_secure`` are not parameters — they
are the production posture, fixed here (C3-16/REQ-85).
"""
secrets = EnvFileSecretsProvider(paths=_secrets_paths())
root = data_root()
key_store = KeyStore(secrets, root)
settings_store = SettingsStore(root)
factory = ModelAdapterFactory(secrets, key_store)
# Tavily when keyed, documented keyless DuckDuckGo otherwise (G-008).
search, _search_name = resolve_search_adapter(secrets)
return create_app(
storage_dir=root,
data_dir=root,
search_adapter=search,
secrets_provider=secrets,
key_store=key_store,
settings_store=settings_store,
model_factory=factory,
auth_enabled=True, # HARDWIRED — the parameter exists for the test factory only
cookie_secure=True, # behind the TLS-front proxy (E-4)
)
# Module-level app for `uvicorn researchx.api.main:app`. This is the
# production object; it is built from live wiring (never fakes).
app = build_app()
+505
View File
@@ -0,0 +1,505 @@
"""Disk read-models (T-3.2, D-114, D-115 — REQ-76..79).
Pure functions over ``runs/<owner_id>/<run_id>/`` disk artifacts —
NEVER the in-memory RunRegistry (D-115): every read endpoint stays
correct after a restart with zero in-process actors (P-8 full
closure). Payloads are shaped EXACTLY to the existing frontend types
(``frontend/src/lib/types.ts``): ``ReportLibrary``/``ReportSummary``,
``ReportConnection``, ``ProjectGraph`` nodes/edges, ``InboxItem``,
``SuggestedQuery`` — camelCase, field-for-field, so T-3.9's surface
swaps need zero component rewrites (REQ-54).
Categorization is deterministic + dumb by design: a methodology
folder (``deep-report``/``rapid-answer``) plus topic tokens from plan
sub-questions. No model calls, no synthesis — the same disk tree
always categorizes identically.
Open-question semantics (D-114): inbox items are plan sub-questions
of the user's runs, ``done`` iff the run reached DONE — anything
else (post-sweep CANCELLED included) stays ``open``; suggestions are
those open items joined with source run titles — never synthetic.
"""
import json
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
# --------------------------------------------------------------- wire shapes
@dataclass(frozen=True)
class ReportFolder:
"""matches frontend ReportFolder — id == ReportSummary.category."""
id: str
name: str
description: str
def payload(self) -> dict[str, str]:
return {"id": self.id, "name": self.name, "description": self.description}
@dataclass(frozen=True)
class ReportSummary:
"""matches frontend ReportSummary — the library card."""
id: str
runId: str
title: str
question: str
methodologySlug: str
category: str
generatedAt: str
citationCount: int
def payload(self) -> dict[str, Any]:
return {
"id": self.id,
"runId": self.runId,
"title": self.title,
"question": self.question,
"methodologySlug": self.methodologySlug,
"category": self.category,
"generatedAt": self.generatedAt,
"citationCount": self.citationCount,
}
@dataclass(frozen=True)
class ReportConnection:
"""matches frontend ReportConnection — a shared-source/topic edge."""
fromReportId: str
toReportId: str
kind: str # "shared-sources" | "related-topic"
label: str
def payload(self) -> dict[str, str]:
return {
"fromReportId": self.fromReportId,
"toReportId": self.toReportId,
"kind": self.kind,
"label": self.label,
}
@dataclass(frozen=True)
class GraphNode:
id: str
topic: str
linkedSources: list[str]
findings: list[str]
def payload(self) -> dict[str, Any]:
return {
"id": self.id,
"topic": self.topic,
"linkedSources": self.linkedSources,
"findings": self.findings,
}
@dataclass(frozen=True)
class GraphEdge:
fromId: str
toId: str
sharedSourceCount: int
def payload(self) -> dict[str, Any]:
return {
"fromId": self.fromId,
"toId": self.toId,
"sharedSourceCount": self.sharedSourceCount,
}
@dataclass(frozen=True)
class InboxItem:
"""matches frontend InboxItem — "<run_id>#<idx>" ids (D-114)."""
id: str
question: str
sourceRunId: str
status: str # "open" | "done" (in_progress is mock-only vocabulary)
def payload(self) -> dict[str, str]:
return {
"id": self.id,
"question": self.question,
"sourceRunId": self.sourceRunId,
"status": self.status,
}
@dataclass(frozen=True)
class SuggestedQuery:
"""matches frontend SuggestedQuery — an open item + run title."""
id: str
question: str
sourceRunId: str
sourceReportTitle: str
def payload(self) -> dict[str, str]:
return {
"id": self.id,
"question": self.question,
"sourceRunId": self.sourceRunId,
"sourceReportTitle": self.sourceReportTitle,
}
# ------------------------------------------------------------- disk scanning
_METHOD_NAMES = {"rapid-answer": "Rapid Answer", "deep-report": "Deep Report"}
_QUOTE_MAX = 200
def _is_scannable(run_dir: Path) -> bool:
"""Defensive scan filter (D-115): dotfiles/tmp leftovers skipped."""
name = run_dir.name
return run_dir.is_dir() and not name.startswith(".") and ".tmp" not in name
@dataclass
class RunDiskRecord:
"""One run as it sits on disk — the read-model's only input."""
run_id: str
state: dict[str, Any]
report: dict[str, Any] | None = None
ledger: dict[str, Any] | None = None
# derived, filled by load:
status: str = field(default="", init=False)
def __post_init__(self) -> None:
self.status = str(self.state.get("state", ""))
@property
def is_done(self) -> bool:
return self.status == "DONE"
@property
def question(self) -> str:
return str(self.state.get("question", ""))
@property
def methodology_slug(self) -> str:
return str(self.state.get("methodology_slug", ""))
@property
def created_at(self) -> str:
return str(self.state.get("created_at", ""))
@property
def sub_questions(self) -> list[str]:
plan = self.state.get("plan")
if not isinstance(plan, dict):
return []
raw = plan.get("sub_questions", [])
if not isinstance(raw, list):
return []
return [str(q) for q in raw]
def report_title(self) -> str:
"""The report.json title, else methodology-name — question."""
if self.report is not None and self.report.get("title"):
return str(self.report["title"])
name = _METHOD_NAMES.get(self.methodology_slug, "Research")
return f"{name} — {self.question}"
def citation_count(self) -> int:
if self.report is not None:
citations = self.report.get("citations")
if isinstance(citations, list):
return len(citations)
return 0
def ledger_urls(self) -> list[str]:
if self.ledger is None:
return []
entries = self.ledger.get("entries")
if not isinstance(entries, list):
return []
return [str(e.get("url")) for e in entries if isinstance(e, dict) and e.get("url")]
def _read_json(path: Path) -> dict[str, Any] | None:
try:
raw: object = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return None
return raw if isinstance(raw, dict) else None
def list_runs(base_dir: Path, owner_id: str) -> list[RunDiskRecord]:
"""Scan this owner's partition: state.json (+ report.json,
ledger.json where present). Dotfiles/tmp skipped defensively
(D-115); corrupt or stateless directories are skipped, never
fatal. Records are ordered most-recent first (created_at desc,
run_id as the stable tiebreaker)."""
runs_dir = base_dir / "runs" / owner_id
if not runs_dir.is_dir():
return []
records: list[RunDiskRecord] = []
for run_dir in sorted(runs_dir.iterdir()):
if not _is_scannable(run_dir):
continue
state = _read_json(run_dir / "state.json")
if state is None:
continue
records.append(
RunDiskRecord(
run_id=run_dir.name,
state=state,
report=_read_json(run_dir / "report.json"),
ledger=_read_json(run_dir / "ledger.json"),
)
)
records.sort(key=lambda r: (r.created_at, r.run_id), reverse=True)
return records
# ------------------------------------------------------- categorization (dumb)
_STOPWORDS = {
"the", "a", "an", "of", "in", "on", "and", "or", "to", "for", "is",
"are", "what", "which", "how", "do", "does", "why", "when", "with",
"that", "this", "these", "those", "it", "its", "by", "at", "be",
"can", "could", "should", "will", "into", "from", "about", "across",
}
_TOPIC_TOKEN_RE = re.compile(r"[a-z][a-z0-9-]*")
def topic_tokens(record: RunDiskRecord) -> list[str]:
"""Deterministic topic tokens: salient words of the plan
sub-questions (lowercased, deduped, order-preserving)."""
tokens: list[str] = []
for question in record.sub_questions:
for token in _TOPIC_TOKEN_RE.findall(question.lower()):
if token not in _STOPWORDS and token not in tokens:
tokens.append(token)
return tokens
def categorize(record: RunDiskRecord) -> str:
"""The folder id a report files under: methodology + topic token.
Deterministic + dumb (REQ-76): the methodology forms the folder
base and the first topic token of the plan names the sub-folder —
the same disk tree always categorizes identically. Runs without a
plan (pre-approval) fall back to the bare methodology slug; a run
with neither resolves to "research".
"""
slug = record.methodology_slug or "research"
tokens = topic_tokens(record)
if not tokens:
return slug
return f"{slug}-{tokens[0]}"
def _category_name(category: str) -> str:
"""Humanize a category id: 'deep-report-battery' -> 'Deep Report:
Battery'. The methodology slug (multi-hyphen, e.g.
'deep-report') is stripped as a unit before the topic token is
titled."""
for slug, name in _METHOD_NAMES.items():
prefix = f"{slug}-"
if category.startswith(prefix):
tail = category[len(prefix) :]
return f"{name}: {tail.replace('-', ' ').title()}" if tail else name
return category.replace("-", " ").title()
def _category_description(category: str) -> str:
return f"Auto-filed from {category.replace('-', ' ')} research."
def _summary(record: RunDiskRecord) -> ReportSummary:
generated_at = (
str(record.report.get("generated_at")) if record.report is not None else record.created_at
)
return ReportSummary(
id=f"rep-{record.run_id}",
runId=record.run_id,
title=record.report_title(),
question=record.question,
methodologySlug=record.methodology_slug,
category=categorize(record),
generatedAt=generated_at or datetime.now(UTC).isoformat(),
citationCount=record.citation_count(),
)
# ----------------------------------------------------------------- library
def library(base_dir: Path, owner_id: str, sort: str = "recent") -> dict[str, Any]:
"""GET /api/library payload: folders + ReportSummary[] (REQ-76).
Only DONE runs with a report are "stored reports" — a cancelled or
failed run produced nothing worth filing. ``sort`` is "recent"
(default, created_at desc — the scan order) or "title" (A-Z).
"""
if sort not in ("recent", "title"):
sort = "recent"
done = [r for r in list_runs(base_dir, owner_id) if r.is_done]
reports = [_summary(r) for r in done]
if sort == "title":
reports.sort(key=lambda summary: (summary.title.casefold(), summary.runId))
seen: dict[str, int] = {}
for summary in reports:
seen[summary.category] = seen.get(summary.category, 0) + 1
folders = [
ReportFolder(
id=category,
name=_category_name(category),
description=_category_description(category),
).payload()
for category in sorted(seen)
]
return {
"folders": folders,
"reports": [summary.payload() for summary in reports],
}
# ------------------------------------------------------------ connections
def _shared_urls(first: RunDiskRecord, second: RunDiskRecord) -> set[str]:
return set(first.ledger_urls()) & set(second.ledger_urls())
def connections(base_dir: Path, owner_id: str) -> list[dict[str, Any]]:
"""GET /api/library/connections payload (REQ-77): shared-source
edges (>=1 ledger URL overlap) + related-topic edges (same
methodology + shared category). Between the user's DONE runs
only; edges point older -> newer (the fixture convention).
"""
done = list(reversed([r for r in list_runs(base_dir, owner_id) if r.is_done]))
edges: list[ReportConnection] = []
for i, first in enumerate(done):
for second in done[i + 1 :]:
shared = _shared_urls(first, second)
if shared:
edges.append(
ReportConnection(
fromReportId=f"rep-{first.run_id}",
toReportId=f"rep-{second.run_id}",
kind="shared-sources",
label=f"{len(shared)} shared source{'s' if len(shared) != 1 else ''}",
)
)
elif categorize(first) == categorize(second):
edges.append(
ReportConnection(
fromReportId=f"rep-{first.run_id}",
toReportId=f"rep-{second.run_id}",
kind="related-topic",
label="Adjacent topic",
)
)
return [edge.payload() for edge in edges]
# -------------------------------------------------------------------- graph
def _report_finding(record: RunDiskRecord) -> str:
"""The node's first 'finding': the report's title (a real artifact
value — never fabricated prose)."""
return record.report_title()
def graph(base_dir: Path, owner_id: str) -> dict[str, Any]:
"""GET /api/graph payload (REQ-77): DONE runs as GraphNodes, the
same edges as connections() as GraphEdges."""
done = list(reversed([r for r in list_runs(base_dir, owner_id) if r.is_done]))
nodes = [
GraphNode(
id=record.run_id,
topic=record.report_title(),
linkedSources=sorted(set(record.ledger_urls())),
findings=[_report_finding(record)],
).payload()
for record in done
]
edges: list[GraphEdge] = []
for i, first in enumerate(done):
for second in done[i + 1 :]:
shared = _shared_urls(first, second)
if shared:
edges.append(GraphEdge(first.run_id, second.run_id, len(shared)))
elif categorize(first) == categorize(second):
edges.append(GraphEdge(first.run_id, second.run_id, 0))
return {"nodes": nodes, "edges": [edge.payload() for edge in edges]}
# -------------------------------------------------------------------- tasks
def tasks(base_dir: Path, owner_id: str) -> list[dict[str, str]]:
"""GET /api/tasks payload (REQ-78, D-114): plan sub-questions as
InboxItems, ``done`` iff the source run reached DONE — post-sweep
CANCELLED runs surface ``open``, never answered."""
items: list[InboxItem] = []
for record in list_runs(base_dir, owner_id):
status = "done" if record.is_done else "open"
for idx, question in enumerate(record.sub_questions):
items.append(
InboxItem(
id=f"{record.run_id}#{idx}",
question=question,
sourceRunId=record.run_id,
status=status,
)
)
return [item.payload() for item in items]
# -------------------------------------------------------------- suggestions
def suggestions(base_dir: Path, owner_id: str) -> list[dict[str, str]]:
"""GET /api/suggestions payload (REQ-79): the OPEN inbox items
joined with their source run titles — derived from the user's own
runs only, never synthetic; empty for a fresh user."""
suggested: list[SuggestedQuery] = []
for record in list_runs(base_dir, owner_id):
if record.is_done:
continue
for idx, question in enumerate(record.sub_questions):
suggested.append(
SuggestedQuery(
id=f"sug-{record.run_id}#{idx}",
question=question,
sourceRunId=record.run_id,
sourceReportTitle=record.report_title(),
)
)
return [item.payload() for item in suggested]
__all__ = [
"GraphEdge",
"GraphNode",
"InboxItem",
"ReportConnection",
"ReportFolder",
"ReportSummary",
"RunDiskRecord",
"SuggestedQuery",
"categorize",
"connections",
"graph",
"library",
"list_runs",
"suggestions",
"tasks",
"topic_tokens",
]
+61
View File
@@ -0,0 +1,61 @@
"""Startup sweep (D-115, G-101).
On app construction, every non-terminal ``state.json`` under the
storage base is rewritten as CANCELLED — in-flight actors died with the
previous process; rehydration is out of v0.3 scope. CANCELLED is legal
from every non-terminal state (states.py — including PLANNING per
G-101, pinned by test). The registry-side pruning lives in app.py
(RunRegistry.prune, P-6).
"""
from pathlib import Path
from researchx.engine.run_state import RunState
from researchx.engine.states import TERMINAL_STATES, RunStateName
def _atomic_write(path: Path, content: str) -> None:
import os
import secrets
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
tmp_path.replace(path)
def sweep_in_flight_runs(base_dir: Path) -> list[tuple[str, str]]:
"""Rewrite every non-terminal state.json under base_dir as CANCELLED.
Scans ``base_dir/runs/<owner_id>/<run_id>/state.json`` (the
partitioned layout, D-106). Returns the swept (owner_id, run_id)
pairs. Corrupt state files are skipped defensively — a sweep must
never prevent startup.
"""
swept: list[tuple[str, str]] = []
runs_dir = base_dir / "runs"
if not runs_dir.is_dir():
return swept
for owner_dir in sorted(runs_dir.iterdir()):
if not owner_dir.is_dir() or owner_dir.name.startswith("."):
continue
for run_dir in sorted(owner_dir.iterdir()):
if not run_dir.is_dir() or run_dir.name.startswith("."):
continue
state_path = run_dir / "state.json"
if not state_path.is_file():
continue
try:
state = RunState.model_validate_json(state_path.read_text(encoding="utf-8"))
except (ValueError, OSError):
# Corrupt/unreadable state files are not swept, never
# fatal — a sweep must never prevent startup.
continue
if state.state in TERMINAL_STATES:
continue
state.state = RunStateName.CANCELLED # legal from every non-terminal state (G-101)
_atomic_write(state_path, state.model_dump_json())
swept.append((owner_dir.name, run_dir.name))
return swept
+24
View File
@@ -0,0 +1,24 @@
"""Auth package: user accounts, sessions, guard, and routes (REQ-69/70/71)."""
from researchx.auth.models import (
AdminCreateUserBody,
LoginBody,
RegisterBody,
UserPublic,
to_public,
)
from researchx.auth.sessions import Session, SessionStore
from researchx.auth.users import User, UserError, UserStore
__all__ = [
"AdminCreateUserBody",
"LoginBody",
"RegisterBody",
"Session",
"SessionStore",
"User",
"UserError",
"UserPublic",
"UserStore",
"to_public",
]
@@ -0,0 +1,56 @@
"""Admin user-management routes (T-1.5, REQ-70).
Mounted on the PROTECTED router (session + CSRF already enforced by
``require_user``); every route additionally requires the admin role.
Responses expose ``UserPublic`` only — never a password hash. Account
creation enforces the same >=12 password policy as registration;
deactivation sets ``active=False`` and immediately revokes every
session of the target user (data is retained).
"""
from fastapi import APIRouter, Depends, HTTPException
from researchx.auth.guard import Guard
from researchx.auth.models import AdminCreateUserBody, UserPublic, to_public
from researchx.auth.sessions import SessionStore
from researchx.auth.users import UserError, UserStore
def build_admin_router(
users: UserStore,
sessions: SessionStore,
admin_guard: Guard,
) -> APIRouter:
"""Build the admin user surface (mounted at /api/admin, admin-only)."""
router = APIRouter(
prefix="/api/admin",
tags=["admin"],
dependencies=[Depends(admin_guard)],
)
@router.get("/users", response_model=list[UserPublic])
async def list_users() -> list[UserPublic]:
return [to_public(u) for u in users.list()]
@router.post("/users", status_code=201, response_model=UserPublic)
async def create_user(body: AdminCreateUserBody) -> UserPublic:
try:
user = await users.create_user(
email=body.email,
password=body.password,
display_name=body.display_name or body.email.split("@")[0],
role=body.role,
)
except UserError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return to_public(user)
@router.post("/users/{user_id}/deactivate", response_model=UserPublic)
async def deactivate_user(user_id: str) -> UserPublic:
updated = users.deactivate(user_id)
if updated is None:
raise HTTPException(status_code=404, detail="user not found")
sessions.revoke_all_for(user_id) # deactivation kills sessions immediately
return to_public(updated)
return router
+109
View File
@@ -0,0 +1,109 @@
"""Auth guard — session + CSRF enforcement as route dependencies (T-1.4).
Deliberately NOT middleware: router dependencies appear per-route, are
overridable via ``app.dependency_overrides``, and never wrap SSE
response bodies (no BaseHTTPMiddleware near the event stream).
Enforcement order:
1. missing/invalid/expired session -> 401 "authentication required"
2. non-GET without the CSRF header -> 403 "missing request header"
3. missing or deactivated user -> 401 "account disabled"
then the sliding-expiry ``touch()``.
When auth is disabled (dev/test harness only — a create_app parameter,
never an env var readable in production, C3-16), the dev guard
resolves a synthetic admin user for every request so all routes behave
exactly as authenticated.
"""
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from fastapi import HTTPException, Request
from researchx.auth.models import UserPublic, to_public
from researchx.auth.sessions import Session, SessionStore
from researchx.auth.users import User, UserStore
SESSION_COOKIE = "researchx_session"
CSRF_HEADER = "x-researchx-request"
DEV_USER_ID = "dev00000"
Guard = Callable[[Request], Awaitable[UserPublic]]
def _dev_user() -> User:
"""The synthetic admin every request resolves when auth is disabled."""
return User(
user_id=DEV_USER_ID,
email="dev@localhost",
password_hash="", # never persisted, never verified
display_name="Dev User",
role="admin",
active=True,
created_at=datetime.now(UTC),
)
def require_csrf_header(request: Request) -> None:
"""CSRF defense for public POSTs (login/register) — same rule as the guard."""
if request.method != "GET" and CSRF_HEADER not in request.headers:
raise HTTPException(status_code=403, detail="missing request header")
def build_guard(users: UserStore, sessions: SessionStore) -> Guard:
"""Build the authenticated-mode require_user dependency."""
async def require_user(request: Request) -> UserPublic:
token = request.cookies.get(SESSION_COOKIE)
session: Session | None = sessions.get(token) if token else None
if session is None:
raise HTTPException(status_code=401, detail="authentication required")
if request.method != "GET" and CSRF_HEADER not in request.headers:
raise HTTPException(status_code=403, detail="missing request header")
user: User | None = users.get(session.user_id)
if user is None or not user.active:
raise HTTPException(status_code=401, detail="account disabled")
sessions.touch(session) # sliding expiry
return to_public(user)
return require_user
def build_dev_guard() -> Guard:
"""Build the dev-mode guard: every request resolves the synthetic dev user."""
async def require_user(request: Request) -> UserPublic:
del request # dev mode: no session, no CSRF — routes behave as before
return to_public(_dev_user())
return require_user
def build_admin_guard(guard: Guard) -> Guard:
"""Wrap a guard with the admin role check (REQ-70).
The returned dependency resolves the wrapped guard first, so
``app.dependency_overrides`` on either composes correctly.
"""
async def require_admin(request: Request) -> UserPublic:
user = await guard(request)
if user.role != "admin":
raise HTTPException(status_code=403, detail="admin role required")
return user
return require_admin
__all__ = [
"CSRF_HEADER",
"DEV_USER_ID",
"SESSION_COOKIE",
"Guard",
"build_admin_guard",
"build_dev_guard",
"build_guard",
"require_csrf_header",
]
+336
View File
@@ -0,0 +1,336 @@
"""Per-user encrypted key store (T-2.2, REQ-73 — D-108/D-109).
AES-256-GCM envelope encryption of per-user model keys:
- KEK: ``RESEARCHX_KEK`` from the env-file secrets **'keys' scope**,
accepted as urlsafe-base64 or hex, exactly 32 bytes (AES-256). Decode
or length errors raise typed ``AdapterError`` (never a silent
fallback). With no KEK configured the store is DISABLED — honestly,
with a reason string — and the credential chain degrades to
server/keyless (C3-06).
- AAD: ``user_id.encode()`` — a key file copied under another user_id
fails ``InvalidTag`` at decrypt time (cross-user binding by
construction).
- Nonce: fresh ``os.urandom(12)`` per write; a GCM nonce must NEVER
repeat under the same KEK.
- File: ``keys/<user_id>.json`` under the data root — ``{v, alg,
updated_at, entries}`` — written atomically (tmp + ``os.replace``)
with mode 0600.
- Crypto runs in ``asyncio.to_thread`` so the event loop is never
blocked by file I/O.
- Scope: ``OLLAMA_API_KEY`` only in v0.3 (D-109).
KEK-loss posture (G-105, binding):
The KEK is NOT recoverable from anything in this repository or in
the running process. If the owner loses it, every per-user
encrypted key becomes unrecoverable — by design, that is the point
of envelope encryption. The honest degradation is: the key store
reports disabled, the resolution chain falls to server/keyless,
users re-enter their keys under a re-provisioned KEK, and the owner
MUST back up the KEK off-repo. Rotation tooling is out of v0.3
scope (recorded). A changed KEK requires a process restart — the
secrets provider loads once at construction.
Corruption is never a silent tier drop: ``InvalidTag`` (AAD tamper,
wrong KEK, garbage ciphertext) and malformed envelopes both surface as
typed errors (``KeyStoreError``), so the credential chain can report an
honest error state instead of quietly falling back.
"""
import asyncio
import base64
import os
import secrets
import threading
from datetime import UTC, datetime
from pathlib import Path
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from pydantic import BaseModel, Field, ValidationError
from researchx.adapters.secrets import SecretsProvider
from researchx.adapters.types import AdapterError
KEK_NAME = "RESEARCHX_KEK"
KEY_SCOPE = "OLLAMA_API_KEY"
_NONCE_BYTES = 12
_KEY_BYTES = 32
class KeyStoreError(AdapterError):
"""Typed key-store failure: corrupt/tampered envelope, bad KEK shape.
``kind`` lets callers (routes, factory, tests) distinguish the
failure mode without parsing messages:
- ``disabled`` — no usable KEK; the store refuses honestly.
- ``unreadable`` — the on-disk envelope is corrupt garbage.
- ``tampered`` — decrypt failed (InvalidTag: wrong user AAD,
wrong KEK, or bit-rot ciphertext). NEVER a
silent None — the tier must not silently drop.
- ``bad_kek`` — the configured KEK is malformed (decode/length).
"""
def __init__(self, kind: str, message: str) -> None:
super().__init__(message)
self.kind = kind
class _KeyEntry(BaseModel):
nonce: str
ct: str
updated_at: str
class _KeyEnvelope(BaseModel):
v: int
alg: str = "AES-256-GCM"
updated_at: str
entries: dict[str, _KeyEntry] = Field(default_factory=dict)
def _now() -> str:
return datetime.now(UTC).isoformat()
def load_kek_bytes(secrets: SecretsProvider) -> bytes | None:
"""Resolve ``RESEARCHX_KEK`` to raw 32 key bytes.
Accepted encodings: urlsafe-base64 (with or without padding) or
hex. Anything that decodes but does not yield exactly 32 bytes, or
fails to decode at all, raises ``KeyStoreError(kind="bad_kek")``
(the research-file's typed AdapterError contract). ``None`` is
returned only when the KEK is simply absent — the honest disabled
state, distinct from a broken one.
"""
raw = secrets.resolve(KEK_NAME)
if not raw:
return None
raw = raw.strip()
# Try hex first (unambiguous: hex keys are [0-9a-fA-F]{64}); then
# urlsafe b64 with flexible padding.
try:
if len(raw) == _KEY_BYTES * 2 and all(c in "0123456789abcdefABCDEF" for c in raw):
return bytes.fromhex(raw)
except ValueError as exc: # pragma: no cover — fromhex on checked chars can't fail
raise KeyStoreError("bad_kek", f"{KEK_NAME} is not valid hex") from exc
padded = raw + "=" * (-len(raw) % 4)
try:
decoded = base64.urlsafe_b64decode(padded.encode("ascii"))
except (ValueError, TypeError) as exc:
raise KeyStoreError(
"bad_kek", f"{KEK_NAME} is not valid urlsafe-b64 or hex"
) from exc
if len(decoded) != _KEY_BYTES:
raise KeyStoreError(
"bad_kek", f"{KEK_NAME} must decode to exactly 32 bytes (AES-256), got {len(decoded)}"
)
return decoded
def _encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _decode(token: str) -> bytes:
return base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
class KeyStore:
"""Per-user encrypted key store (``keys/<user_id>.json``).
The store is ENABLED iff a valid KEK resolves at construction.
Every public method is async; file/crypto work runs in
``asyncio.to_thread``.
"""
def __init__(self, secrets: SecretsProvider, data_root: Path) -> None:
self._secrets = secrets
self._root = data_root / "keys"
self._lock = threading.Lock()
self._kek: bytes | None
self._disabled_reason: str | None = None
try:
self._kek = load_kek_bytes(secrets)
except KeyStoreError as exc:
# A malformed KEK is a hard misconfiguration: treat the
# store as disabled with the honest reason, never a crash
# and never a silent enable with the wrong key.
self._kek = None
self._disabled_reason = f"{KEK_NAME} is misconfigured: {exc}"
if self._kek is None and self._disabled_reason is None:
self._disabled_reason = (
f"no {KEK_NAME} configured (env-file secrets 'keys' scope); "
"per-user model keys cannot be stored — the credential chain "
"falls to the server tier or keyless localhost"
)
# ------------------------------------------------------------ state
@property
def enabled(self) -> bool:
return self._kek is not None
@property
def disabled_reason(self) -> str | None:
return self._disabled_reason
def _require_kek(self) -> bytes:
assert self._kek is not None # internal invariant; callers gate on enabled
return self._kek
def _path(self, user_id: str) -> Path:
return self._root / f"{user_id}.json"
# ------------------------------------------------------- sync workers
def _read_envelope(self, user_id: str) -> _KeyEnvelope | None:
path = self._path(user_id)
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
try:
return _KeyEnvelope.model_validate_json(raw)
except ValidationError as exc:
raise KeyStoreError(
"unreadable",
f"key envelope for user {user_id} is corrupt ({exc.error_count()} errors)",
) from exc
def _set_key_locked(self, user_id: str, raw_key: str) -> None:
kek = self._require_kek()
nonce = os.urandom(_NONCE_BYTES) # fresh per write — NEVER reuse under the KEK
ct = AESGCM(kek).encrypt(nonce, raw_key.encode("utf-8"), user_id.encode("utf-8"))
envelope = _KeyEnvelope(
v=1,
alg="AES-256-GCM",
updated_at=_now(),
entries={KEY_SCOPE: _KeyEntry(nonce=_encode(nonce), ct=_encode(ct), updated_at=_now())},
)
path = self._path(user_id)
self._root.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(envelope.model_dump_json(indent=2))
handle.flush()
os.fsync(handle.fileno())
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, path) # atomic — a crash never leaves a torn file
def _get_key_locked(self, user_id: str) -> str | None:
envelope = self._read_envelope(user_id)
if envelope is None:
return None
entry = envelope.entries.get(KEY_SCOPE)
if entry is None:
return None
kek = self._require_kek()
try:
plain = AESGCM(kek).decrypt(
_decode(entry.nonce), _decode(entry.ct), user_id.encode("utf-8")
)
except InvalidTag as exc:
# AAD binding (user_id) failed, the KEK changed, or the
# ciphertext bit-rotted. This is a typed error, NEVER a
# silent None — a silent drop would let the tier fall to
# server/keyless without the user knowing their key is
# unreadable (C3-06).
raise KeyStoreError(
"tampered",
f"key envelope for user {user_id} failed authentication "
"(wrong user binding, changed KEK, or tampering)",
) from exc
except (ValueError, TypeError) as exc:
raise KeyStoreError(
"unreadable", f"key envelope for user {user_id} has invalid encoding"
) from exc
return plain.decode("utf-8")
# ------------------------------------------------------------ API
async def set_key(self, user_id: str, scope: str, raw_key: str) -> None:
"""Encrypt and persist *raw_key* for *user_id* under *scope*.
Rejected with a typed error when the store is disabled — an
honest refusal, never a silent no-op.
"""
if scope != KEY_SCOPE:
raise KeyStoreError("disabled", f"unsupported key scope: {scope} (v0.3: {KEY_SCOPE} only)")
if not self.enabled:
raise KeyStoreError("disabled", self._disabled_reason or "key store disabled")
def _work() -> None:
with self._lock:
self._set_key_locked(user_id, raw_key)
await asyncio.to_thread(_work)
async def get_key(self, user_id: str) -> str | None:
"""Decrypt and return the user's key, or None when none stored.
Raises ``KeyStoreError`` on tamper/corruption (never a silent
None), and when the store is disabled with a stored envelope
that cannot be read.
"""
if not self.enabled:
# Without a KEK nothing can be read — but an envelope that
# EXISTS is surfaced as an error state (the user has a key
# we cannot serve), not silently None.
def _peek() -> bool:
return self._path(user_id).is_file()
if await asyncio.to_thread(_peek):
raise KeyStoreError(
"disabled",
f"a key is stored for user {user_id} but the key store is disabled ({self._disabled_reason})",
)
return None
def _work() -> str | None:
with self._lock:
return self._get_key_locked(user_id)
return await asyncio.to_thread(_work)
async def masked_status(self, user_id: str) -> dict[str, object]:
"""Masked-only status: ``{configured, masked}`` — NEVER the raw key.
When the store is disabled the status says so honestly
(``disabled: True`` + reason). A tampered/unreadable envelope
is surfaced as an error field rather than ``configured: False``
— never a silent tier drop.
"""
status: dict[str, object] = {"configured": False, "masked": None}
if not self.enabled:
status["disabled"] = True
status["reason"] = self._disabled_reason
def _peek() -> bool:
return self._path(user_id).is_file()
if await asyncio.to_thread(_peek):
# An unreadable stored key is an error state, not "not configured".
raise KeyStoreError(
"disabled",
f"a key is stored for user {user_id} but the key store is disabled ({self._disabled_reason})",
)
return status
def _work() -> dict[str, object]:
with self._lock:
envelope = self._read_envelope(user_id)
if envelope is None or envelope.entries.get(KEY_SCOPE) is None:
return {"configured": False, "masked": None, "disabled": False}
key = self._get_key_locked(user_id) # surfaces tamper as KeyStoreError
return {
"configured": True,
"masked": f"…{key[-4:]}" if key and len(key) >= 4 else "…",
"disabled": False,
}
return await asyncio.to_thread(_work)
__all__ = ["KEK_NAME", "KEY_SCOPE", "KeyStore", "KeyStoreError", "load_kek_bytes"]
+60
View File
@@ -0,0 +1,60 @@
"""Auth API request/response contracts (T-1.3).
These are the wire models for the register/login/logout/me routes and
the admin user surface. Password hashes never appear in any of them
(REQ-69: no password-hash material in any API response).
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from researchx.auth.users import MIN_PASSWORD_LENGTH, User
# Lightweight email shape check — the auth surface needs a stable,
# normalizable identifier, not a full RFC 5322 validation (and pulling
# pydantic[email] would add an unplanned dependency).
_EMAIL_RE = r"^[^\s@]+@[^\s@]+\.[^\s@]+$"
class RegisterBody(BaseModel):
email: str = Field(pattern=_EMAIL_RE, max_length=254)
password: str = Field(min_length=MIN_PASSWORD_LENGTH)
display_name: str = Field(default="", max_length=120)
class LoginBody(BaseModel):
email: str = Field(pattern=_EMAIL_RE, max_length=254)
password: str = Field(min_length=1)
class AdminCreateUserBody(BaseModel):
email: str = Field(pattern=_EMAIL_RE, max_length=254)
password: str = Field(min_length=MIN_PASSWORD_LENGTH)
display_name: str = Field(default="", max_length=120)
role: str = Field(default="user", pattern=r"^(admin|user)$")
class UserPublic(BaseModel):
"""A user as exposed on the wire — no password_hash, ever."""
model_config = ConfigDict(from_attributes=True)
user_id: str
email: str
display_name: str
role: str
active: bool
created_at: datetime
def to_public(user: User) -> UserPublic:
"""Project a stored ``User`` to its public wire shape."""
return UserPublic(
user_id=user.user_id,
email=user.email,
display_name=user.display_name,
role=user.role,
active=user.active,
created_at=user.created_at,
)
+119
View File
@@ -0,0 +1,119 @@
"""Auth routes: register / login / logout / me (T-1.4, REQ-69/70).
Mounted on a PUBLIC router at /api/auth/* (no require_user dependency)
— but login/register POSTs still enforce the CSRF header (C3-05) via
``Depends(require_csrf_header)``.
Route semantics:
- register: open only while the user store is empty; the first user
becomes admin, everything else 403s. The only await between the
emptiness check and the insert is the argon2 hash; re-checking under
the store lock keeps it race-free (D-104, single worker by design).
- login: uniform 401 "invalid email or password" for every failure
mode (no user enumeration); a fresh token is issued every login
(session-fixation defense by rotation); the httpOnly cookie is set
with SameSite=Lax, Path=/, no Domain attribute, Secure when
configured for TLS-fronted hosts.
- logout: revokes the presented token immediately.
- me: the current user (session only — no CSRF header on GETs).
"""
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from researchx.auth.guard import SESSION_COOKIE, require_csrf_header
from researchx.auth.models import LoginBody, RegisterBody, UserPublic, to_public
from researchx.auth.sessions import SessionStore
from researchx.auth.users import UserError, UserStore
_COOKIE_MAX_AGE = 14 * 24 * 3600
def build_auth_router(
users: UserStore,
sessions: SessionStore,
cookie_secure: bool,
) -> APIRouter:
"""Build the public auth router (mounted at /api/auth)."""
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=SESSION_COOKIE,
value=token,
max_age=_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
path="/",
secure=cookie_secure,
)
def _presented_token(request: Request) -> str | None:
return request.cookies.get(SESSION_COOKIE)
@router.post(
"/register",
status_code=201,
response_model=UserPublic,
dependencies=[Depends(require_csrf_header)],
)
async def register(body: RegisterBody) -> UserPublic:
# Fast-path 403 for the common case; the authoritative
# emptiness re-check happens under the store lock inside
# create_first_admin (D-104) so a concurrent duplicate
# register cannot both succeed.
if not users.empty():
raise HTTPException(status_code=403, detail="registration closed")
try:
user = await users.create_first_admin(
email=body.email,
password=body.password,
display_name=body.display_name or body.email.split("@")[0],
)
except UserError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
return to_public(user)
@router.post(
"/login",
response_model=UserPublic,
dependencies=[Depends(require_csrf_header)],
)
async def login(body: LoginBody, response: Response) -> UserPublic:
user = users.get_by_email(body.email)
if user is None or not await users.verify_password(user, body.password):
raise HTTPException(status_code=401, detail="invalid email or password")
if not user.active:
raise HTTPException(status_code=401, detail="invalid email or password")
# Token rotation: a fresh token on every login kills session
# fixation by construction (D-101).
token = sessions.generate_token()
sessions.create(token, user.user_id)
_set_session_cookie(response, token)
if await users.needs_rehash(user):
await users.rehash_password(user, body.password)
return to_public(user)
@router.post(
"/logout",
dependencies=[Depends(require_csrf_header)],
)
async def logout(request: Request, response: Response) -> dict[str, bool]:
token = _presented_token(request)
if token:
sessions.revoke(token)
response.delete_cookie(key=SESSION_COOKIE, path="/")
return {"ok": True}
@router.get("/me", response_model=UserPublic)
async def me(request: Request) -> UserPublic:
token = _presented_token(request)
session = sessions.get(token) if token else None
if session is None:
raise HTTPException(status_code=401, detail="authentication required")
user = users.get(session.user_id)
if user is None or not user.active:
raise HTTPException(status_code=401, detail="authentication required")
sessions.touch(session)
return to_public(user)
return router
+161
View File
@@ -0,0 +1,161 @@
"""Session store — server-side, opaque tokens, durable JSON mirror (D-101).
Tokens are ``secrets.token_urlsafe(32)``; only their sha256 hash is
stored (in memory and in ``<data_root>/sessions.json``). Expiry is
14-day sliding: every authenticated request calls ``touch()`` which
pushes ``expires_at`` forward, persisting at most once per 60s.
Revocation is immediate (logout deletes the presented token;
deactivation deletes every token of the user).
"""
import asyncio
import hashlib
import json
import os
import secrets
import threading
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field, ValidationError
SESSION_TTL_SECONDS = 14 * 24 * 3600
_PERSIST_THROTTLE_SECONDS = 60.0
class Session(BaseModel):
token_hash: str
user_id: str
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
last_seen: datetime = Field(default_factory=lambda: datetime.now(UTC))
expires_at: datetime
model_config = ConfigDict(frozen=True)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
class SessionStore:
"""In-memory map keyed by token_hash + durable sessions.json mirror."""
def __init__(self, path: Path) -> None:
self._path = path
self._sessions: dict[str, Session] = {}
self._lock = threading.Lock()
self._last_persist = 0.0
self._load()
# ------------------------------------------------------------ persistence
def _load(self) -> None:
if not self._path.exists():
return
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return
for record in raw.get("sessions", []):
try:
session = Session.model_validate(record)
except (ValidationError, KeyError, TypeError):
continue # defensive: skip corrupt records on load
self._sessions[session.token_hash] = session
def _persist_locked(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"sessions": [s.model_dump(mode="json") for s in self._sessions.values()]
}
tmp_path = self._path.with_name(f"{self._path.name}.{secrets.token_hex(4)}.tmp")
content = json.dumps(payload, indent=2)
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, self._path)
self._last_persist = time.monotonic()
def _persist(self) -> None:
with self._lock:
self._persist_locked()
# ------------------------------------------------------------- queries
def get(self, token: str) -> Session | None:
"""Resolve a presented token; expired or unknown tokens yield None."""
token_hash = _hash_token(token)
session = self._sessions.get(token_hash)
if session is None:
return None
if session.expires_at <= datetime.now(UTC):
with self._lock:
expired = self._sessions.pop(token_hash, None)
if expired is not None:
self._persist_locked()
return None
return session
# ------------------------------------------------------------- mutation
def create(self, token: str, user_id: str) -> Session:
"""Store a new session for *token* (only its hash is kept)."""
now = datetime.now(UTC)
session = Session(
token_hash=_hash_token(token),
user_id=user_id,
created_at=now,
last_seen=now,
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
)
with self._lock:
self._sessions[session.token_hash] = session
self._persist_locked()
return session
def touch(self, session: Session) -> None:
"""Sliding expiry: push expires_at forward, throttled persist."""
now = datetime.now(UTC)
renewed = Session(
token_hash=session.token_hash,
user_id=session.user_id,
created_at=session.created_at,
last_seen=now,
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
)
with self._lock:
current = self._sessions.get(session.token_hash)
if current is None or current != session:
return # stale or raced touch — nothing to extend
self._sessions[renewed.token_hash] = renewed
if time.monotonic() - self._last_persist > _PERSIST_THROTTLE_SECONDS:
self._persist_locked()
def revoke(self, token: str) -> None:
"""Immediately delete the session for *token* (logout)."""
token_hash = _hash_token(token)
with self._lock:
if self._sessions.pop(token_hash, None) is not None:
self._persist_locked()
def revoke_all_for(self, user_id: str) -> None:
"""Immediately delete every session of *user_id* (deactivation)."""
with self._lock:
doomed = [h for h, s in self._sessions.items() if s.user_id == user_id]
for token_hash in doomed:
del self._sessions[token_hash]
if doomed:
self._persist_locked()
def generate_token(self) -> str:
return secrets.token_urlsafe(32)
# ----------------------------------------------------------- asyncio shim
async def arevoke(self, token: str) -> None:
await asyncio.to_thread(self.revoke, token)
async def arevoke_all_for(self, user_id: str) -> None:
await asyncio.to_thread(self.revoke_all_for, user_id)
+104
View File
@@ -0,0 +1,104 @@
"""Per-user settings persistence (T-2.3, REQ-72 — D-111).
``settings/<user_id>.json`` under the data root, written atomically
(tmp + ``os.replace``). Fresh users get defaults on read (GET never
writes). The ``UserSettings`` model carries the per-user surface the
Settings page binds to: planner/writer model names, methodology
defaults (resolved dial overrides per methodology — free-form dict in
v0.3, validated at use time), and the appearance slot.
The storage directory is server-managed and display-only (D-111) — it
is NOT part of this payload; the config status route surfaces it.
"""
import asyncio
import os
import secrets
import threading
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field, ValidationError
from researchx.adapters.types import AdapterError
# Default model names — the same defaults the live smoke tier uses
# (gpt-oss:20b on ollama.com). These are fallbacks only: the frontend
# Settings page always shows the current value.
DEFAULT_PLANNER_MODEL = "gpt-oss:20b"
DEFAULT_WRITER_MODEL = "gpt-oss:20b"
class UserSettings(BaseModel):
planner_model: str = DEFAULT_PLANNER_MODEL
writer_model: str = DEFAULT_WRITER_MODEL
methodology_defaults: dict[str, Any] = Field(default_factory=dict)
appearance: str = "system"
class SettingsStoreError(AdapterError):
"""Typed settings-store failure (corrupt on-disk payload)."""
class SettingsStore:
"""Per-user settings, one JSON file per user, atomic writes."""
def __init__(self, data_root: Path) -> None:
self._root = data_root / "settings"
self._lock = threading.Lock()
def _path(self, user_id: str) -> Path:
return self._root / f"{user_id}.json"
# ------------------------------------------------------------ sync
def _read_sync(self, user_id: str) -> UserSettings:
path = self._path(user_id)
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return UserSettings() # fresh user: defaults, no write on read
try:
return UserSettings.model_validate_json(raw)
except ValidationError as exc:
raise SettingsStoreError(
f"settings payload for user {user_id} is corrupt ({exc.error_count()} errors)"
) from exc
def _update_sync(self, user_id: str, partial: UserSettings) -> UserSettings:
current = self._read_sync(user_id)
merged = current.model_copy(update=partial.model_dump(exclude_unset=True))
# Validate the merged payload BEFORE it touches disk: a null
# field slipped past model_copy (it skips validation) would
# poison the file — every later read would raise corrupt.
merged = UserSettings.model_validate(merged.model_dump())
path = self._path(user_id)
self._root.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(merged.model_dump_json(indent=2))
handle.flush()
os.fsync(handle.fileno())
tmp_path.replace(path) # atomic
return merged
# ------------------------------------------------------------ async
async def get(self, user_id: str) -> UserSettings:
return await asyncio.to_thread(self._read_sync, user_id)
async def update(self, user_id: str, partial: UserSettings) -> UserSettings:
def _work() -> UserSettings:
with self._lock:
return self._update_sync(user_id, partial)
return await asyncio.to_thread(_work)
__all__ = [
"DEFAULT_PLANNER_MODEL",
"DEFAULT_WRITER_MODEL",
"SettingsStore",
"SettingsStoreError",
"UserSettings",
]
+237
View File
@@ -0,0 +1,237 @@
"""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 update_profile(
self,
user_id: str,
*,
display_name: str | None = None,
email: str | None = None,
) -> User:
"""Update display name and/or email (T-3.6 — GET/PUT /api/profile).
The email must remain unique across OTHER users (a taken
address raises UserError); the email index is maintained
atomically under the store lock. No deletion exists.
"""
with self._lock:
user = self._by_id.get(user_id)
if user is None:
raise UserError("unknown user")
new_email = user.email
if email is not None:
new_email = email.casefold()
other = self._by_email.get(new_email)
if other is not None and other.user_id != user_id:
raise UserError("email already registered")
new_display = display_name if display_name is not None else user.display_name
updated = user.model_copy(update={"email": new_email, "display_name": new_display})
if new_email != user.email:
old = self._by_email.get(user.email)
if old is not None and old.user_id == user_id:
del self._by_email[user.email]
self._by_id[user_id] = updated
self._by_email[new_email] = updated
self._persist_locked()
return updated
async def create_first_admin(self, *, email: str, password: str, display_name: str) -> User:
"""Atomically create the first user, who is admin by construction.
The emptiness check + insert both happen under the store lock;
the only await (the argon2 hash) precedes the check. Combined
with the single uvicorn worker this makes registration open
exactly once (D-104).
"""
if len(password) < MIN_PASSWORD_LENGTH:
raise UserError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
normalized = email.casefold()
password_hash = await self.hash_password(password) # the one allowed await
with self._lock:
if self._by_id:
raise UserError("registration closed")
if self._by_email.get(normalized) is not None:
raise UserError("email already registered")
user = User(
user_id=generate_slug(),
email=normalized,
password_hash=password_hash,
display_name=display_name,
role="admin",
)
self._by_id[user.user_id] = user
self._by_email[user.email] = user
self._persist_locked()
return user
def deactivate(self, user_id: str) -> User | None:
with self._lock:
user = self._by_id.get(user_id)
if user is None:
return None
updated = user.model_copy(update={"active": False})
self._by_id[user_id] = updated
self._by_email[updated.email] = updated
self._persist_locked()
return updated
+25
View File
@@ -0,0 +1,25 @@
"""8-char slug ids — the single generator + validation (P-14 consolidation).
Run ids and user ids share one alphabet and one shape: 8 lowercase
alphanumeric characters, validated by ``^[a-z0-9]{8}$`` (D-105 — the
user_id doubles as a filesystem-safe partition key). Both
``adapters.local_storage`` and ``config.run_config`` import from here so
there is exactly one generator left in the tree.
"""
import re
import secrets
ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"
SLUG_RE = re.compile(r"^[a-z0-9]{8}$")
def generate_slug() -> str:
"""Generate a random 8-char slug from the shared alphabet."""
return "".join(secrets.choice(ALPHABET) for _ in range(8))
def is_valid_slug(value: str) -> bool:
"""Return True when *value* is a well-formed 8-char slug."""
return SLUG_RE.match(value) is not None
+31
View File
@@ -0,0 +1,31 @@
"""Data-root resolution (D-107).
All mutable, host-persisted state (users.json, sessions.json, runs/,
keys/, settings/) lives under one directory:
- ``RESEARCHX_DATA_DIR`` when set (container: ``/var/lib/researchx``),
- else ``backend/var/researchx`` relative to the repository root (dev).
Replaces the cwd-dependent ``Path.cwd() / ".researchx"`` storage default.
"""
import os
from pathlib import Path
_ENV_VAR = "RESEARCHX_DATA_DIR"
# src/researchx/config/paths.py -> repo root is four parents up.
_REPO_ROOT = Path(__file__).resolve().parents[4]
_DEV_DEFAULT = _REPO_ROOT / "backend" / "var" / "researchx"
def data_root() -> Path:
"""Return the mutable data root, creating it on demand.
The directory (and its parent chain) is created lazily so that
importing this module never touches the filesystem.
"""
raw = os.environ.get(_ENV_VAR)
root = Path(raw).expanduser() if raw else _DEV_DEFAULT
root.mkdir(parents=True, exist_ok=True)
return root
+6 -2
View File
@@ -1,4 +1,3 @@
import secrets
from collections.abc import Mapping
from datetime import datetime
from typing import Any
@@ -6,6 +5,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from researchx.config.dials import Dials
from researchx.config.ids import generate_slug
from researchx.methodologies.registry import MethodologyRegistry
@@ -23,6 +23,9 @@ class RunConfig(BaseModel, frozen=True):
resolved_dials: Dials
created_at: datetime
overrides: dict[str, Any] = Field(default_factory=dict)
# Owning user (REQ-71). Optional = serialization-compatible with
# pre-v0.3 config files (D-106: no migration).
owner_id: str | None = None
model_config = ConfigDict(frozen=True)
@@ -43,7 +46,8 @@ def resolve_dials(
def new_run_id() -> str:
return secrets.token_hex(4)
"""Generate a run id (P-14: single shared generator — config/ids.py)."""
return generate_slug()
__all__ = ["RunConfig", "UnknownOverrideError", "new_run_id", "resolve_dials"]
+33
View File
@@ -11,6 +11,7 @@ from researchx.adapters.types import AdapterError, ModelMessage
from researchx.adapters.url_guard import url_allowed
from researchx.config.dials import Dials
from researchx.config.run_config import RunConfig, resolve_dials
from researchx.engine.artifacts import build_evidence_artifact, build_report_artifact
from researchx.engine.events import EventBus, RunEvent
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
@@ -328,6 +329,33 @@ class RunActor:
)
checklist = export_checklist(ledger)
await self._storage.write_run_artifact(self._config.run_id, "checklist.md", checklist)
# Structured artifacts (T-3.1, D-113): report.json +
# evidence.json, written HERE — the only point where the
# per-actor EvidenceStore still exists (it dies with the actor;
# the disk read-models of D-114/D-115 can never rebuild it).
# Marker normalization [p-N] -> [^pN] happens at this
# persistence boundary ONLY (C3-10): report.md keeps the raw
# form the trust-loop regexes depend on.
evidence_artifact = build_evidence_artifact(
run_id=self._config.run_id, evidence=self._store
)
report_artifact = build_report_artifact(
run_id=self._config.run_id,
owner_id=self.state.owner_id,
question=self._config.question,
methodology_name=self._methodology.name,
methodology_slug=self._config.methodology_slug,
report_markdown=report,
ledger=ledger,
evidence=self._store,
generated_at=ledger.generated_at,
)
await self._storage.write_run_artifact(
self._config.run_id, "evidence.json", evidence_artifact.model_dump_json(indent=2)
)
await self._storage.write_run_artifact(
self._config.run_id, "report.json", report_artifact.model_dump_json(indent=2)
)
await self._checkpoint(RunStateName.DONE)
await self._publish("run_done", {"run_id": self._config.run_id})
@@ -351,7 +379,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 +396,6 @@ def make_run_config(
resolved_dials=dials,
overrides=dict(overrides),
created_at=datetime.now(UTC),
owner_id=owner_id,
)
return rid, config
+227
View File
@@ -0,0 +1,227 @@
"""Structured run artifacts (T-3.1, D-113): report.json + evidence.json.
Built at DONE inside ``RunActor._verify_phase`` — the ONLY point where
the per-actor ``EvidenceStore`` is still alive (it is GC'd with the
actor; D-115 disk read-models come later and can never see it).
**Marker normalization happens HERE ONLY (C3-10).** The writer emits
``[p-N]`` — the trust-loop regexes (``anchoring.py``,
``ledger.py``) and the actor's own ``_cited_urls`` depend on that raw
form, so ``report.md`` is never touched. The Reading Room parses
``[^pN]`` (``report-body.ts`` matches ``[A-Za-z0-9]+`` only —
``[^p-1]`` does NOT match), therefore report.json section bodies and
the id fields of both artifacts carry the normalized ``pN`` id form.
Both artifacts are versioned (``v: 1``) so later schema changes stay
detectable per file (D-106 old artifacts stay disposable/readable).
"""
import re
from datetime import datetime
from pydantic import BaseModel, Field
from researchx.evidence.store import EvidenceStore
from researchx.trust.ledger import CitationLedger
#: The writer's raw marker form — the ONLY pattern normalized here.
_MARKER_RE = re.compile(r"\[p-(\d+)\]")
#: ATX headings (H1..H6) — section delimiters for report.json.
_HEADING_RE = re.compile(r"^#{1,6}[ \t]+(.*)$", re.MULTILINE)
#: A lone H1 — the report title source.
_H1_RE = re.compile(r"^#[ \t]+(.*)$", re.MULTILINE)
#: Quote display cap — matches EvidenceStore's ``quote`` convention.
_QUOTE_MAX_CHARS = 200
def normalize_passage_id(passage_id: str) -> str:
"""``p-N`` -> ``pN`` (the id form report.json/evidence.json carry)."""
match = re.fullmatch(r"p-(\d+)", passage_id)
return f"p{match.group(1)}" if match else passage_id
def normalize_markers(text: str) -> str:
"""``[p-N]`` -> ``[^pN]`` — report.json section bodies ONLY."""
return _MARKER_RE.sub(lambda match: f"[^p{match.group(1)}]", text)
def extract_title(markdown: str, fallback: str) -> str:
"""First H1 of the report, else the methodology/question fallback."""
match = _H1_RE.search(markdown)
if match is not None:
return match.group(1).strip()
return fallback
def split_sections(markdown: str) -> list[tuple[str, str]]:
"""Split report.md into (heading, body) chunks on ATX headings.
Every ``^#{1,6}\\s`` heading starts a section and is included as
that section's heading (the H1 chunk included — the title is a
separate field and the frontend binding decides presentation).
Content before the first heading becomes a ``("", body)`` section
rather than being dropped — no content loss, no fabrication.
"""
sections: list[tuple[str, str]] = []
matches = list(_HEADING_RE.finditer(markdown))
if not matches:
body = markdown.strip()
if body:
sections.append(("", body))
return sections
preamble = markdown[: matches[0].start()].strip()
if preamble:
sections.append(("", preamble))
for index, match in enumerate(matches):
heading = match.group(1).strip()
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(markdown)
sections.append((heading, markdown[start:end].strip()))
return sections
class EvidencePassage(BaseModel):
"""One stored passage — id in the normalized ``pN`` form."""
passage_id: str
url: str
title: str
quote: str
text: str
class EvidenceArtifact(BaseModel):
"""evidence.json — the serialized EvidenceStore at DONE."""
v: int = 1
run_id: str
passages: list[EvidencePassage] = Field(default_factory=list)
class ReportSection(BaseModel):
heading: str
body: str
class ReportCitation(BaseModel):
"""A page-anchored citation — passage-level, quote included."""
passage_id: str # pN form
url: str
title: str
quote: str
health: str
authority_tier: str
class ReportArtifact(BaseModel):
"""report.json — the structured Reading-Room payload at DONE."""
v: int = 1
run_id: str
owner_id: str | None = None
title: str
question: str
methodology_slug: str
generated_at: datetime
sections: list[ReportSection] = Field(default_factory=list)
citations: list[ReportCitation] = Field(default_factory=list)
def _page_titles(evidence: EvidenceStore) -> dict[str, str]:
return {page.url: page.title for page in evidence.pages()}
def _clip(quote: str) -> str:
return quote[:_QUOTE_MAX_CHARS]
def build_evidence_artifact(*, run_id: str, evidence: EvidenceStore) -> EvidenceArtifact:
"""Serialize the full EvidenceStore — ids renumbered ``pN``.
Every passage the run collected, full text included: this is the
only persisted copy (the in-actor store dies at DONE).
"""
titles = _page_titles(evidence)
passages = [
EvidencePassage(
passage_id=normalize_passage_id(passage.passage_id),
url=passage.url,
title=titles.get(passage.url, ""),
quote=_clip(passage.quote),
text=passage.text,
)
for passage in evidence.writer_context()
]
return EvidenceArtifact(run_id=run_id, passages=passages)
def build_report_artifact(
*,
run_id: str,
owner_id: str | None,
question: str,
methodology_name: str,
methodology_slug: str,
report_markdown: str,
ledger: CitationLedger,
evidence: EvidenceStore,
generated_at: datetime,
) -> ReportArtifact:
"""Build report.json from the raw report.md + ledger + evidence.
Section bodies carry normalized ``[^pN]`` markers (C3-10); the raw
``report.md`` on disk is untouched. Citations are the ledger's cited
URL entries resolved to their stored passages — real quotes from
the evidence in hand, never fabricated.
"""
title = extract_title(report_markdown, fallback=f"{methodology_name} — {question}")
sections = [
ReportSection(heading=heading, body=normalize_markers(body))
for heading, body in split_sections(report_markdown)
]
titles = _page_titles(evidence)
citations: list[ReportCitation] = []
for entry in ledger.entries:
for passage_id in entry.passages:
passage = evidence.get(passage_id)
if passage is None:
continue
citations.append(
ReportCitation(
passage_id=normalize_passage_id(passage_id),
url=entry.url,
title=titles.get(entry.url, "") or entry.url,
quote=_clip(passage.quote),
health=entry.health,
authority_tier=entry.authority_tier,
)
)
return ReportArtifact(
run_id=run_id,
owner_id=owner_id,
title=title,
question=question,
methodology_slug=methodology_slug,
generated_at=generated_at,
sections=sections,
citations=citations,
)
__all__ = [
"EvidenceArtifact",
"EvidencePassage",
"ReportArtifact",
"ReportCitation",
"ReportSection",
"build_evidence_artifact",
"build_report_artifact",
"extract_title",
"normalize_markers",
"normalize_passage_id",
"split_sections",
]
+3 -1
View File
@@ -13,12 +13,14 @@ from researchx.methodologies import registry
async def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
owner_id = "demo0001" # 8-char owner slug (P-9: demo passes the owner)
run_id, config = make_run_config(
question="How do verification pipelines keep citations honest?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
owner_id=owner_id,
)
plan = (
'{"sub_questions": ["verification passes", "quote anchoring"], '
@@ -36,7 +38,7 @@ async def main() -> None:
]
)
search = FakeSearchAdapter(CORPUS)
storage = LocalStorageAdapter(base_dir=base)
storage = LocalStorageAdapter(base_dir=base, owner_id=owner_id)
bus = EventBus()
actor = RunActor(
run_config=config, model=model, search=search, storage=storage, registry=registry,
@@ -18,6 +18,13 @@ 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
# Which credential tier resolved for this run (REQ-74: user|
# server|keyless). None on pre-v0.3.2 state files — old artifacts
# deserialize unchanged (D-106: no migration).
credential_tier: str | None = None
plan: Plan | None = None
depth_cursor: int = 0
depth_target: int = 1
+8 -1
View File
@@ -15,7 +15,14 @@ class RunStateName(StrEnum):
_LEGAL_TRANSITIONS: dict[RunStateName, set[RunStateName]] = {
RunStateName.PLANNING: {RunStateName.AWAITING_APPROVAL, RunStateName.FAILED},
# G-101: PLANNING -> CANCELLED is legal — the restart sweep rewrites
# in-flight PLANNING runs as CANCELLED; semantically a run that has
# not produced a plan can always be abandoned.
RunStateName.PLANNING: {
RunStateName.AWAITING_APPROVAL,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.AWAITING_APPROVAL: {
RunStateName.SEARCHING,
RunStateName.CANCELLED,
+22
View File
@@ -0,0 +1,22 @@
"""Shared test fixtures."""
import pytest
@pytest.fixture(autouse=True)
def _reset_sse_starlette_loop_binding():
"""Reset sse_starlette's module-global shutdown Event before each test.
``sse_starlette.AppStatus.should_exit_event`` is an ``anyio.Event``
memoized at the first SSE response and bound to that event loop.
pytest-asyncio gives every test a fresh loop, so a second SSE test in
one process would die with "Event is bound to a different event
loop". Resetting the global before each test keeps the library's
loop-binding per-test. (Production is unaffected: single process,
single loop, single uvicorn worker by design.)
"""
from sse_starlette.sse import AppStatus
AppStatus.should_exit = False
AppStatus.should_exit_event = None
yield
+4
View File
@@ -137,6 +137,8 @@ async def test_attack_pause_resume_mid_depth_loop_over_http(tmp_path: Path) -> N
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(canned=canned),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
@@ -196,6 +198,8 @@ async def test_attack_cancel_mid_loop_persists(tmp_path: Path) -> None:
+ [ChatResult(content="# never reached", model="fake-writer")]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
+4
View File
@@ -15,6 +15,8 @@ _WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
def _app(tmp_path: Path):
# Zero-key dev harness (C3-16): auth explicitly off — the synthetic
# dev admin resolves for every request.
return create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
@@ -24,6 +26,8 @@ def _app(tmp_path: Path):
]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
+325
View File
@@ -0,0 +1,325 @@
"""Structured artifacts at DONE (T-3.1, D-113): report.json +
evidence.json — marker normalization at the persistence boundary ONLY
(C3-10: report.json bodies carry [^pN], report.md keeps raw [p-N])."""
import asyncio
import contextlib
import json
import re
from datetime import UTC, datetime
from pathlib import Path
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import ChatResult
from researchx.engine.actor import EngineError, RunActor, make_run_config
from researchx.engine.artifacts import (
build_evidence_artifact,
build_report_artifact,
extract_title,
normalize_markers,
normalize_passage_id,
split_sections,
)
from researchx.engine.events import EventBus
from researchx.engine.states import RunStateName
from researchx.evidence.store import EvidenceStore
from researchx.methodologies import registry
from researchx.trust.ledger import build_ledger
from researchx.trust.liveness import LivenessStatus
_PLAN = json.dumps(
{
"sub_questions": ["methodology verification", "quote anchoring"],
"source_universe": ["web"],
"outline": ["intro", "findings"],
}
)
# Multi-section report with [p-N] markers on multiple lines.
_WRITER_REPORT = (
"# Battery Advances\n\n"
"The verification pass re-anchored every quoted claim [p-1].\n\n"
"## Findings\n\n"
"Methodology-first selection converts ad-hoc prompting [p-1] "
"into reproducible decisions [p-2].\n\n"
"## Conclusion\n\n"
"The anchored loop holds [p-2].\n"
)
def _canned() -> list[ChatResult]:
return [
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER_REPORT, model="fake-writer"),
]
def _storage(tmp_path: Path) -> LocalStorageAdapter:
return LocalStorageAdapter(base_dir=tmp_path, owner_id="abcd2345")
async def _run_to_done(tmp_path: Path) -> RunActor:
_run_id, config = make_run_config(
question="How do verification pipelines keep citations honest?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
owner_id="abcd2345",
)
model = FakeModelAdapter(canned=_canned())
search = FakeSearchAdapter(CORPUS)
actor = RunActor(
run_config=config,
model=model,
search=search,
storage=_storage(tmp_path),
registry=registry,
event_bus=EventBus(),
)
actor.state.owner_id = "abcd2345"
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
if task.done():
break
await actor.approve()
await task
assert actor.state.state == RunStateName.DONE
return actor
async def _read_json(tmp_path: Path, name: str) -> dict[str, object]:
raw = await _storage(tmp_path).read_run_artifact("abcd2345", name)
return json.loads(raw) # type: ignore[no-any-return]
# ------------------------------------------------------------- unit helpers
def test_normalize_markers_converts_p_dash_form() -> None:
assert normalize_markers("claim [p-1] and [p-12]") == "claim [^p1] and [^p12]"
def test_normalize_passage_id_renumbers() -> None:
assert normalize_passage_id("p-1") == "p1"
assert normalize_passage_id("p-42") == "p42"
def test_split_sections_on_headings() -> None:
sections = split_sections("# Title\n\nintro body\n\n## A\n\na body\n\n### B\n\nb body")
assert sections == [
("Title", "intro body"),
("A", "a body"),
("B", "b body"),
]
def test_split_sections_preamble_and_no_headings() -> None:
assert split_sections("loose text only") == [("", "loose text only")]
assert split_sections("before\n\n# H\n\nafter") == [("", "before"), ("H", "after")]
def test_extract_title_prefers_first_h1() -> None:
assert extract_title("# First H1\n\n## Second\n", "fallback") == "First H1"
assert extract_title("no heading at all", "Deep Report — q?") == "Deep Report — q?"
# --------------------------------------------------------------- build paths
def _fixture_store() -> EvidenceStore:
store = EvidenceStore()
store.add_page(
"https://example.org/verification",
"Verification passes in research pipelines",
(
"The verification pass re-anchored every quoted claim to its stored "
"passage before the report was allowed to ship, so the audit trail "
"stays honest claim by claim."
),
)
return store
def test_build_evidence_artifact_renumbers_ids_and_keeps_text() -> None:
artifact = build_evidence_artifact(run_id="abcd2345", evidence=_fixture_store())
payload = json.loads(artifact.model_dump_json())
assert payload["v"] == 1
assert payload["run_id"] == "abcd2345"
assert len(payload["passages"]) == 1
passage = payload["passages"][0]
assert passage["passage_id"] == "p1" # p-1 -> p1
assert passage["url"] == "https://example.org/verification"
assert passage["title"] == "Verification passes in research pipelines"
assert passage["quote"] == passage["text"][:200]
assert "re-anchored" in passage["text"]
def test_build_report_artifact_sections_and_citations() -> None:
store = _fixture_store()
markdown = "# Report\n\nClaim [p-1] here.\n\n## Findings\n\nAnother [p-1] claim.\n"
ledger = build_ledger(
run_id="abcd2345",
report_text=markdown,
evidence=store,
liveness={"https://example.org/verification": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
artifact = build_report_artifact(
run_id="abcd2345",
owner_id="abcd2345",
question="q?",
methodology_name="Rapid Answer",
methodology_slug="rapid-answer",
report_markdown=markdown,
ledger=ledger,
evidence=store,
generated_at=ledger.generated_at,
)
payload = json.loads(artifact.model_dump_json())
assert payload["v"] == 1
assert payload["owner_id"] == "abcd2345"
assert payload["title"] == "Report"
assert payload["question"] == "q?"
assert payload["methodology_slug"] == "rapid-answer"
assert [s["heading"] for s in payload["sections"]] == ["Report", "Findings"]
# C3-10: normalized markers in section bodies ONLY.
assert "[^p1]" in payload["sections"][0]["body"]
assert "[p-1]" not in payload["sections"][0]["body"]
assert [c["passage_id"] for c in payload["citations"]] == ["p1"]
citation = payload["citations"][0]
assert citation["url"] == "https://example.org/verification"
assert citation["title"] == "Verification passes in research pipelines"
assert citation["quote"].startswith("The verification pass")
assert citation["health"] == "live"
assert citation["authority_tier"] == "reference"
def test_build_report_artifact_title_fallback_uses_methodology_name() -> None:
store = _fixture_store()
ledger = build_ledger(
run_id="abcd2345",
report_text="no heading [p-1]",
evidence=store,
liveness={},
visited_at=datetime.now(UTC),
)
artifact = build_report_artifact(
run_id="abcd2345",
owner_id=None,
question="what about X?",
methodology_name="Deep Report",
methodology_slug="deep-report",
report_markdown="no heading [p-1]",
ledger=ledger,
evidence=store,
generated_at=ledger.generated_at,
)
assert artifact.title == "Deep Report — what about X?"
# --------------------------------------------------- at-DONE persistence
async def test_done_writes_report_and_evidence_artifacts(tmp_path: Path) -> None:
await _run_to_done(tmp_path)
report_payload = await _read_json(tmp_path, "report.json")
evidence_payload = await _read_json(tmp_path, "evidence.json")
# report.json shape
assert report_payload["v"] == 1
assert report_payload["run_id"] == "abcd2345"
assert report_payload["owner_id"] == "abcd2345"
assert report_payload["title"] == "Battery Advances"
assert report_payload["methodology_slug"] == "rapid-answer"
assert "generated_at" in report_payload
assert [s["heading"] for s in report_payload["sections"]] == [ # type: ignore[call-overload]
"Battery Advances",
"Findings",
"Conclusion",
]
# evidence.json shape
assert evidence_payload["v"] == 1
assert evidence_payload["run_id"] == "abcd2345"
assert len(evidence_payload["passages"]) >= 2 # type: ignore[arg-type]
async def test_marker_normalization_at_boundary_only(tmp_path: Path) -> None:
"""C3-10 pinned end to end: report.json bodies carry [^pN]; the raw
report.md keeps [p-N] (the trust-loop regexes' form)."""
await _run_to_done(tmp_path)
report_payload = await _read_json(tmp_path, "report.json")
markdown = await _storage(tmp_path).read_run_artifact("abcd2345", "report.md")
sections = report_payload["sections"]
assert isinstance(sections, list) and sections
joined = "\n".join(str(s["body"]) for s in sections)
assert "[^p1]" in joined
assert "[p-1]" not in joined
assert "[^p2]" in joined
# The raw artifact is untouched — trust-loop regexes keep working.
assert "[p-1]" in markdown
assert "[^p1]" not in markdown
async def test_citations_carry_real_quotes_and_pn_ids(tmp_path: Path) -> None:
await _run_to_done(tmp_path)
report_payload = await _read_json(tmp_path, "report.json")
evidence_payload = await _read_json(tmp_path, "evidence.json")
citations = report_payload["citations"]
assert isinstance(citations, list) and citations
for citation in citations:
assert re.fullmatch(r"^p\d+$", str(citation["passage_id"]))
assert citation["url"].startswith("https://")
assert citation["quote"], "citation quote must be a real stored quote"
assert len(str(citation["quote"])) <= 200
assert citation["health"] in {"live", "stale", "dead"}
assert citation["authority_tier"]
# Evidence carries full texts: quote is the clipped head of text.
passages = evidence_payload["passages"]
assert isinstance(passages, list) and passages
evidence_by_id = {str(p["passage_id"]): p for p in passages}
for citation in citations:
passage = evidence_by_id[str(citation["passage_id"])]
assert str(passage["quote"]) == str(citation["quote"])
assert len(str(passage["text"])) >= len(str(passage["quote"]))
async def test_artifacts_only_written_at_done(tmp_path: Path) -> None:
"""A run cancelled at AWAITING_APPROVAL leaves no structured
artifacts — the write point is _verify_phase, nothing earlier."""
_run_id, config = make_run_config(
question="q",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
owner_id="abcd2345",
)
actor = RunActor(
run_config=config,
model=FakeModelAdapter(canned=_canned()),
search=FakeSearchAdapter(CORPUS),
storage=_storage(tmp_path),
registry=registry,
event_bus=EventBus(),
)
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
actor.cancel()
await actor.approve()
with contextlib.suppress(asyncio.CancelledError, EngineError):
await task
run_dir = tmp_path / "runs" / "abcd2345" / "abcd2345"
assert not (run_dir / "report.json").exists()
assert not (run_dir / "evidence.json").exists()
+590
View File
@@ -0,0 +1,590 @@
"""Auth route suite (T-1.9 backend half, REQ-69/70 — RESEARCH §11 list).
Full adversarial surface of the auth layer, run with
``auth_enabled=True``: first-user-admin, registration closing, uniform
login failures, session/logout semantics, CSRF enforcement, admin
create/deactivate (deactivation kills sessions), and the
no-password-hash-anywhere guarantee.
"""
import asyncio
import json
from pathlib import Path
import httpx
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.types import ChatResult
from researchx.api.app import create_app
from researchx.auth.guard import CSRF_HEADER
_PLAN = json.dumps(
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
)
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
_ADMIN_EMAIL = "admin@example.org"
_ADMIN_PASSWORD = "correct-horse-battery"
_SECOND_EMAIL = "second@example.org"
_SECOND_PASSWORD = "another-long-password"
_HEADERS = {CSRF_HEADER: "1"}
def _app(tmp_path: Path):
return create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER, model="fake-writer"),
]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
)
async def _client(tmp_path: Path) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=_app(tmp_path)),
base_url="http://testserver",
)
async def _register_first_admin(client: httpx.AsyncClient) -> httpx.Response:
return await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
headers=_HEADERS,
)
async def _login(client: httpx.AsyncClient, email: str, password: str) -> httpx.Response:
return await client.post(
"/api/auth/login", json={"email": email, "password": password}, headers=_HEADERS
)
# ------------------------------------------------------------------ register
async def test_healthz_outside_api_is_public(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
async def test_first_register_creates_admin(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await _register_first_admin(client)
assert response.status_code == 201
body = response.json()
assert body["role"] == "admin"
assert body["email"] == _ADMIN_EMAIL
assert body["active"] is True
assert len(body["user_id"]) == 8
assert "password_hash" not in body
assert "password" not in body
async def test_second_register_is_403(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
response = await client.post(
"/api/auth/register",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
assert response.status_code == 403
assert response.json()["detail"] == "registration closed"
async def test_register_rejects_short_password(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": "short"},
headers=_HEADERS,
)
assert response.status_code == 422 # pydantic contract rejects < 12
async def test_register_requires_csrf_header(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
)
assert response.status_code == 403
assert response.json()["detail"] == "missing request header"
async def test_concurrent_register_yields_exactly_one_admin(tmp_path: Path) -> None:
"""D-104 adversarial pin: a simultaneous register storm on a fresh
store must produce exactly one 201/admin — the emptiness check +
insert are atomic under the store lock (create_first_admin), so a
raced duplicate cannot both win. Regression guard for the
create_user-based route that let N concurrent registers through."""
racers = [(f"racer{i}@example.org", f"racing-password-{i:02d}") for i in range(8)]
async with await _client(tmp_path) as client:
responses = await asyncio.gather(
*(
client.post(
"/api/auth/register",
json={"email": email, "password": password},
headers=_HEADERS,
)
for email, password in racers
)
)
winners = [(r, racer) for r, racer in zip(responses, racers, strict=True) if r.status_code == 201]
losers = [r for r in responses if r.status_code == 403]
assert len(winners) == 1, "exactly one register may win the race"
assert len(losers) == 7
assert winners[0][0].json()["role"] == "admin"
# The winner can log in; every losing racer has no account.
_, (winner_email, winner_password) = winners[0]
login = await client.post(
"/api/auth/login",
json={"email": winner_email, "password": winner_password},
headers=_HEADERS,
)
assert login.status_code == 200
for email, password in racers:
if email == winner_email:
continue
denied = await client.post(
"/api/auth/login",
json={"email": email, "password": password},
headers=_HEADERS,
)
assert denied.status_code == 401, "losing racer must have no account"
# -------------------------------------------------------------------- login
async def test_login_sets_httponly_cookie(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
response = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
assert response.status_code == 200
assert response.json()["role"] == "admin"
set_cookie = response.headers["set-cookie"]
assert "researchx_session=" in set_cookie
assert "httponly" in set_cookie.lower()
assert "samesite=lax" in set_cookie.lower()
assert "path=/" in set_cookie.lower()
assert "domain=" not in set_cookie.lower().replace("samesite=lax", "")
assert "secure" not in set_cookie.lower() # cookie_secure=False in tests
async def test_login_wrong_password_uniform_401(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
for email, password in [
(_ADMIN_EMAIL, "wrong-password-here"),
("ghost@example.org", _ADMIN_PASSWORD),
]:
response = await _login(client, email, password)
assert response.status_code == 401
assert response.json()["detail"] == "invalid email or password"
async def test_login_token_rotation(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
first = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
first_cookie = first.headers["set-cookie"].split(";")[0]
second = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
second_cookie = second.headers["set-cookie"].split(";")[0]
assert first_cookie != second_cookie # fresh token every login
# ----------------------------------------------------------- guard + CSRF
async def test_unauthenticated_401_on_protected_routes(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
for method, path in [
("GET", "/api/methodologies"),
("GET", "/api/config"),
("GET", "/api/runs/abcd2345"),
("GET", "/api/runs/abcd2345/events"),
("GET", "/api/runs/abcd2345/report"),
("GET", "/api/admin/users"),
]:
response = await client.request(method, path)
assert response.status_code == 401, f"{method} {path} leaked without auth"
assert response.json()["detail"] == "authentication required"
async def test_csrf_header_required_on_non_get(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
# Session cookie attached, but no CSRF header on a POST.
response = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
)
assert response.status_code == 403
assert response.json()["detail"] == "missing request header"
async def test_gets_exempt_from_csrf_header(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
assert (await client.get("/api/methodologies")).status_code == 200
assert (await client.get("/api/auth/me")).status_code == 200
# ----------------------------------------------------------- me + logout
async def test_me_returns_current_user(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.get("/api/auth/me")
assert response.status_code == 200
body = response.json()
assert body["email"] == _ADMIN_EMAIL
assert "password_hash" not in body
async def test_logout_revokes_session(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
assert (await client.get("/api/methodologies")).status_code == 200
response = await client.post("/api/auth/logout", headers=_HEADERS)
assert response.status_code == 200
assert (await client.get("/api/methodologies")).status_code == 401
async def test_deactivated_user_401(tmp_path: Path) -> None:
app = _app(tmp_path)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _register_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
assert created.status_code == 201
second_id = created.json()["user_id"]
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
await second.post(
"/api/auth/login",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
assert (await second.get("/api/methodologies")).status_code == 200
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
deactivated = await admin.post(
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
)
assert deactivated.status_code == 200
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
# The deactivated user's session was revoked server-side.
response = await second.get("/api/methodologies")
assert response.status_code == 401
# A fresh login against the deactivated account also 401s —
# uniformly, without revealing the reason (no enumeration).
login = await second.post(
"/api/auth/login",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
assert login.status_code == 401
assert login.json()["detail"] == "invalid email or password"
# ------------------------------------------------------------------- admin
async def test_admin_list_users_no_hashes(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.get("/api/admin/users")
assert response.status_code == 200
body = response.json()
assert len(body) == 1
raw = json.dumps(body)
assert "password_hash" not in raw
assert _ADMIN_PASSWORD not in raw
assert "argon2" not in raw
async def test_admin_create_user_then_user_login(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
assert created.status_code == 201
body = created.json()
assert body["role"] == "user"
assert body["active"] is True
login = await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
assert login.status_code == 200
assert login.json()["role"] == "user"
async def test_admin_create_rejects_short_password(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": "short"},
headers=_HEADERS,
)
assert response.status_code == 422
async def test_admin_deactivate_revokes_sessions(tmp_path: Path) -> None:
app = _app(tmp_path)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as admin:
await _register_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
second_id = created.json()["user_id"]
# A separate client (separate cookie jar) for the second user.
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as second:
await second.post(
"/api/auth/login",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
assert (await second.get("/api/methodologies")).status_code == 200
# Deactivation from the admin session kills the second session.
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as admin:
await _register_first_admin(admin) # store shared; already admin
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
deactivated = await admin.post(
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
)
assert deactivated.status_code == 200
assert deactivated.json()["active"] is False
response = await second.get("/api/methodologies")
assert response.status_code == 401 # session revoked by deactivation
async def test_non_admin_cannot_reach_admin_routes(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
response = await client.get("/api/admin/users")
assert response.status_code == 403
assert response.json()["detail"] == "admin role required"
async def test_admin_deactivate_unknown_user_404(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.post("/api/admin/users/zzzz9999/deactivate", headers=_HEADERS)
assert response.status_code == 404
# ------------------------------------------------------ authenticated runs
async def test_cross_owner_access_404_on_every_run_endpoint(tmp_path: Path) -> None:
"""Two-user isolation (REQ-71): zero visibility across every run
endpoint — cross-owner access is indistinguishable from a missing
run (404, not 403: no existence leak)."""
app = _app(tmp_path)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _register_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/runs",
json={"question": "admin's private question", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
run_id = created.json()["id"]
await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
login = await _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
assert login.status_code == 200
del login # ids don't matter here; counters do
for method, path in [
("GET", f"/api/runs/{run_id}"),
("POST", f"/api/runs/{run_id}/approve"),
("POST", f"/api/runs/{run_id}/pause"),
("POST", f"/api/runs/{run_id}/resume"),
("POST", f"/api/runs/{run_id}/cancel"),
("GET", f"/api/runs/{run_id}/events"),
("GET", f"/api/runs/{run_id}/report"),
("GET", f"/api/runs/{run_id}/ledger"),
]:
response = await second.request(method, path, headers=_HEADERS)
assert response.status_code == 404, f"{method} {path} leaked across owners"
# The owner still sees their run — the 404s were authorization, not
# the run vanishing.
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await admin.get(f"/api/runs/{run_id}")
assert response.status_code == 200
async def test_owner_id_recorded_in_persisted_state(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
login = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
user_id = login.json()["user_id"]
created = await client.post(
"/api/runs",
json={"question": "q", "methodology": "rapid-answer"},
headers=_HEADERS,
)
run_id = created.json()["id"]
state_path = tmp_path / "runs" / user_id / run_id / "state.json"
for _ in range(200): # the actor persists asynchronously
if state_path.exists():
break
await asyncio.sleep(0.01)
assert state_path.exists(), "storage layout must be runs/<user_id>/<run_id>/"
state_text = state_path.read_text()
state = json.loads(state_text)
assert state["owner_id"] == user_id
async def test_cost_counters_owner_keyed(tmp_path: Path) -> None:
app = _app(tmp_path)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _register_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/runs",
json={"question": "q", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
config = await admin.get("/api/config")
counters = config.json()["cost_counters"]
assert counters["runs_created"] == 1 # the admin's own view, owner-keyed
await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
await _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
created = await second.post(
"/api/runs",
json={"question": "q2", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
config = await second.get("/api/config")
assert config.json()["cost_counters"] == {"runs_created": 1, "runs_completed": 0}
async def test_authenticated_run_lifecycle(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await client.post(
"/api/runs",
json={"question": "How do engines run?", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
run_id = created.json()["id"]
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "awaiting_approval":
break
await asyncio.sleep(0.01)
approved = await client.post(f"/api/runs/{run_id}/approve", headers=_HEADERS)
assert approved.status_code == 200
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "done":
break
await asyncio.sleep(0.01)
report = await client.get(f"/api/runs/{run_id}/report")
assert report.status_code == 200
assert "[p-1]" in report.json()["markdown"]
async def test_no_password_hash_in_any_response(tmp_path: Path) -> None:
"""Every response body of a full auth + run session, scanned."""
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
responses = [
await client.get("/api/auth/me"),
await client.get("/api/admin/users"),
await client.get("/api/methodologies"),
await client.get("/api/config"),
await client.get("/api/runs/abcd2345"),
]
for response in responses:
raw = response.text
assert "$argon2" not in raw # no hash material, ever
assert '"password_hash"' not in raw # no hash field, ever
assert _ADMIN_PASSWORD not in raw
assert _SECOND_PASSWORD not in raw
+8 -8
View File
@@ -46,7 +46,7 @@ def _actor(tmp_path: Path) -> tuple[RunActor, EventBus, FakeSearchAdapter]:
)
model = FakeModelAdapter(canned=_canned())
search = FakeSearchAdapter(CORPUS)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
bus = EventBus()
actor = RunActor(
run_config=config,
@@ -71,20 +71,20 @@ async def test_full_walk_reaches_done(tmp_path: Path) -> None:
assert state.state == RunStateName.DONE
assert state.plan is not None and len(state.plan.sub_questions) == 2
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "DONE"
report = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "report.md")
report = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "report.md")
assert "[p-1]" in report
ledger_raw = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
ledger_raw = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact(
"abcd2345", "ledger.json"
)
ledger = json.loads(ledger_raw)
assert ledger["checklist"]["citations_checked"] >= 1
assert ledger["checklist"]["liveness_ratio"] >= 0
checklist = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
checklist = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact(
"abcd2345", "checklist.md"
)
assert "Verification Checklist" in checklist
@@ -121,7 +121,7 @@ async def test_cancel_persists_cancelled_state(tmp_path: Path) -> None:
await actor.approve()
with pytest.raises(asyncio.CancelledError):
await task
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "CANCELLED"
@@ -160,14 +160,14 @@ async def test_planner_non_json_fails_honestly(tmp_path: Path) -> None:
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
task = asyncio.create_task(actor.run())
with pytest.raises(Exception, match="planner returned non-JSON plan"):
await task
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "FAILED"
+5 -2
View File
@@ -31,7 +31,7 @@ async def test_registry_to_fake_model_to_snapshot_round_trip(tmp_path) -> None:
)
assert run_config.resolved_dials.breadth == 3
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
await storage.write_run_artifact(run_id, "run_config.json", run_config.model_dump_json())
restored = RunConfig.model_validate_json(
@@ -70,9 +70,12 @@ def test_override_rejects_unknown_dial() -> None:
def test_new_run_id_format() -> None:
# P-14: one generator, one alphabet (config/ids.py) — the old
# token_hex(4) duplicate is gone.
run_id = new_run_id()
assert len(run_id) == 8
assert all(c in "0123456789abcdef" for c in run_id)
alphabet = set("abcdefghjkmnpqrstuvwxyz23456789")
assert all(c in alphabet for c in run_id)
async def test_fake_search_feeds_evidence_shape() -> None:
+336
View File
@@ -0,0 +1,336 @@
"""Key-store adversarial route suite (T-2.8 — security-engineer hat).
Route-level adversarial coverage beyond the unit suite in
test_key_store_settings.py: corrupt/tampered envelopes surfacing
through the API (typed error, honest degraded tier, never a silent
drop and never a 500), raw-key non-leak across the FULL response
surface of a session that touches the key store, and the create_run
409 honesty on a corrupt envelope (never a wrong-key run, never a
silent tier fall).
"""
import asyncio
import base64
import json
import os
from pathlib import Path
import httpx
import pytest
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.model_factory import ModelAdapterFactory
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.types import ChatResult
from researchx.api.app import create_app
from researchx.auth.guard import CSRF_HEADER
from researchx.auth.key_store import KEK_NAME, KeyStore
_PLAN = json.dumps(
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
)
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
_ADMIN_EMAIL = "admin@example.org"
_ADMIN_PASSWORD = "correct-horse-battery"
_SECOND_EMAIL = "second@example.org"
_SECOND_PASSWORD = "second-long-password"
_HEADERS = {CSRF_HEADER: "1"}
_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode()
_USER_KEY = "adversarial-user-key-4242"
def _app(tmp_path: Path, secrets_values: dict[str, str]):
env_file = tmp_path / ".env.secrets"
env_file.write_text(
"".join(f"{k}={v}\n" for k, v in secrets_values.items()), encoding="utf-8"
)
secrets = EnvFileSecretsProvider(paths=[env_file])
return create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER, model="fake-writer"),
]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
secrets_provider=secrets,
model_factory=ModelAdapterFactory(
secrets,
KeyStore(secrets, tmp_path),
cloud_base_url="http://127.0.0.1:1/v1", # hermetic: unroutable
local_base_url="http://127.0.0.1:1/v1",
),
)
async def _login_admin(client: httpx.AsyncClient) -> None:
await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
headers=_HEADERS,
)
await client.post(
"/api/auth/login",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
headers=_HEADERS,
)
async def _store_key(client: httpx.AsyncClient, key: str) -> None:
response = await client.put(
"/api/config/settings/model-key", json={"key": key}, headers=_HEADERS
)
assert response.status_code == 200
assert response.json()["configured"] is True
def _corrupt_key_file(tmp_path: Path, user_id: str, content: str) -> None:
path = tmp_path / "keys" / f"{user_id}.json"
assert path.is_file(), "the key must be stored before corrupting it"
path.write_text(content, encoding="utf-8")
async def _current_user_id(client: httpx.AsyncClient) -> str:
me = await client.get("/api/auth/me")
return me.json()["user_id"]
async def _await_terminal_or_approval(client: httpx.AsyncClient, run_id: str) -> str:
for _ in range(300):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status in ("done", "failed", "cancelled", "awaiting_approval"):
return status
await asyncio.sleep(0.01)
return status
# ------------------------------------------------- corrupt envelope at the API
async def test_config_status_degrades_honestly_on_garbage_envelope(tmp_path: Path) -> None:
"""Garbage keys/<uid>.json -> config status 200 with a DEGRADED
tier (keyless/server) + the error surfaced in key_status.reason —
typed error surfacing, never a silent tier drop, never a 500."""
app = _app(tmp_path, {KEK_NAME: _KEK})
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
await _login_admin(client)
await _store_key(client, _USER_KEY)
user_id = await _current_user_id(client)
_corrupt_key_file(tmp_path, user_id, "this is garbage, not json")
config = await client.get("/api/config")
assert config.status_code == 200 # never a 500
body = config.json()
assert body["resolved_tier"] in ("server", "keyless") # honest degradation
assert body["key_store"] == "enabled"
assert body["key_status"]["configured"] is False
assert body["key_status"]["reason"] is not None
assert "corrupt" in body["key_status"]["reason"].lower()
async def test_config_status_degrades_honestly_on_tampered_envelope(tmp_path: Path) -> None:
"""A tampered envelope -> same honest degradation + 'failed
authentication' reason — never the decrypted key, never a silent
'user' tier."""
app = _app(tmp_path, {KEK_NAME: _KEK})
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
await _login_admin(client)
await _store_key(client, _USER_KEY)
user_id = await _current_user_id(client)
original = json.loads((tmp_path / "keys" / f"{user_id}.json").read_text())
# Flip ciphertext bytes: the envelope stays valid JSON but
# decrypt must fail (InvalidTag -> typed 'tampered').
ct = base64.urlsafe_b64decode(original["entries"]["OLLAMA_API_KEY"]["ct"] + "===")
flipped = bytes([ct[0] ^ 0xFF]) + ct[1:]
original["entries"]["OLLAMA_API_KEY"]["ct"] = (
base64.urlsafe_b64encode(flipped).decode().rstrip("=")
)
_corrupt_key_file(tmp_path, user_id, json.dumps(original))
config = await client.get("/api/config")
assert config.status_code == 200
body = config.json()
assert body["resolved_tier"] in ("server", "keyless")
assert "authentication" in body["key_status"]["reason"].lower()
assert _USER_KEY not in config.text
async def test_create_run_corrupt_envelope_is_honest_409(tmp_path: Path) -> None:
"""create_run with a corrupt stored envelope -> 409 with the typed
error — the run is NOT created (never a wrong-key run, never a
silent tier fall)."""
app = _app(tmp_path, {KEK_NAME: _KEK})
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
await _login_admin(client)
await _store_key(client, _USER_KEY)
user_id = await _current_user_id(client)
_corrupt_key_file(tmp_path, user_id, "{corrupt")
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
)
assert created.status_code == 409
detail = created.json()["detail"].lower()
assert "corrupt" in detail or "key" in detail
async def test_model_key_put_overwrites_after_corruption(tmp_path: Path) -> None:
"""Recovery path: a user whose envelope corrupted can PUT a fresh
key (atomic overwrite) and return to the 'user' tier."""
app = _app(tmp_path, {KEK_NAME: _KEK})
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
await _login_admin(client)
await _store_key(client, _USER_KEY)
user_id = await _current_user_id(client)
_corrupt_key_file(tmp_path, user_id, "garbage")
config = await client.get("/api/config")
assert config.json()["resolved_tier"] != "user"
await _store_key(client, "fresh-key-value-9999")
config = await client.get("/api/config")
body = config.json()
assert body["resolved_tier"] == "user"
assert body["key_status"]["masked"].endswith("9999")
# ------------------------------------------------------------- leak scan
async def test_no_raw_key_in_full_session_response_surface(tmp_path: Path) -> None:
"""Every response of a full session that stores, reads, and runs
with a user key — scanned for the raw key material (REQ-73)."""
app = _app(tmp_path, {KEK_NAME: _KEK})
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
await _login_admin(client)
put = await client.put(
"/api/config/settings/model-key",
json={"key": _USER_KEY},
headers=_HEADERS,
)
responses: list[httpx.Response] = [put]
for path in [
"/api/config",
"/api/config/settings",
"/api/config/settings/model-key",
"/api/auth/me",
"/api/methodologies",
"/api/admin/users",
]:
responses.append(await client.get(path))
responses.append(
await client.put(
"/api/config/settings", json={"appearance": "dark"}, headers=_HEADERS
)
)
# A run executing under the user tier must not leak the key in
# any run response either.
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
)
assert created.status_code == 201
responses.append(created)
run_id = created.json()["id"]
status = await _await_terminal_or_approval(client, run_id)
responses.append(await client.get(f"/api/runs/{run_id}"))
if status == "awaiting_approval":
responses.append(
await client.post(f"/api/runs/{run_id}/approve", headers=_HEADERS)
)
for response in responses:
assert _USER_KEY not in response.text, "raw model key leaked in a response"
# And never on disk in plaintext.
for path in (tmp_path / "keys").iterdir():
assert _USER_KEY not in path.read_text(encoding="utf-8")
# ---------------------------------------------------- AAD bind via the API
async def test_second_user_cannot_shadow_first_users_key(tmp_path: Path) -> None:
"""Two users, each with their own key: the second user's tier is
'user' via THEIR key, and the first user's key never affects the
second user's resolution (per-user AAD bind end to end)."""
app = _app(tmp_path, {KEK_NAME: _KEK})
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _login_admin(admin)
await _store_key(admin, "first-user-key-aaaa")
config = await admin.get("/api/config")
assert config.json()["resolved_tier"] == "user"
assert config.json()["key_status"]["masked"].endswith("aaaa")
await admin.post(
"/api/admin/users",
json={
"email": _SECOND_EMAIL,
"password": _SECOND_PASSWORD,
"role": "user",
},
headers=_HEADERS,
)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
await second.post(
"/api/auth/login",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
# Before storing a key: keyless (no server key configured).
config = await second.get("/api/config")
assert config.json()["resolved_tier"] == "keyless"
assert "first-user-key" not in config.text
await _store_key(second, "second-user-key-bbbb")
config = await second.get("/api/config")
assert config.json()["resolved_tier"] == "user"
assert config.json()["key_status"]["masked"].endswith("bbbb")
assert "aaaa" not in config.text
# ---------------------------------------------- settings store isolation
async def test_settings_corruption_is_typed_not_crashing(tmp_path: Path) -> None:
"""A corrupt settings file surfaces a typed SettingsStoreError —
the store fails honestly rather than silently serving defaults
over a corrupt payload."""
from researchx.auth.settings import SettingsStore, SettingsStoreError
app = _app(tmp_path, {KEK_NAME: _KEK})
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
await _login_admin(client)
user_id = await _current_user_id(client)
settings_dir = tmp_path / "settings"
settings_dir.mkdir(parents=True, exist_ok=True)
(settings_dir / f"{user_id}.json").write_text("{{not json", encoding="utf-8")
with pytest.raises(SettingsStoreError):
await SettingsStore(tmp_path).get(user_id)
def test_key_store_error_is_adapter_error() -> None:
"""The typed key-store error IS an AdapterError — the factory and
routes can catch one family (C3-06 contract)."""
from researchx.adapters.types import AdapterError
from researchx.auth.key_store import KeyStoreError
assert issubclass(KeyStoreError, AdapterError)
+474
View File
@@ -0,0 +1,474 @@
"""Key store + settings store suite (T-2.2/T-2.3, REQ-72/73).
The adversarial core of the per-user key store: KEK decode rules
(b64/hex/wrong-length/garbage), set/get round-trips, AAD binding
(a copied envelope under another user FAILS — never a wrong-key leak),
nonce uniqueness, masked-only status, disabled-honesty without a KEK,
0600 file permissions, and the settings defaults/update round-trip.
The STEP-4 adversarial extension (corrupt-entry surfacing, concurrent
set_key atomicity, opt-in keyed live tier) lives at the bottom of this
file under the "T-2.8 adversarial extension" banner.
"""
import asyncio
import base64
import json
import os
from pathlib import Path
import pytest
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.auth.key_store import KEK_NAME, KEY_SCOPE, KeyStore, KeyStoreError, load_kek_bytes
from researchx.auth.settings import SettingsStore, UserSettings
_KEK_RAW = os.urandom(32)
_KEK_B64 = base64.urlsafe_b64encode(_KEK_RAW).decode()
_KEK_HEX = _KEK_RAW.hex()
_USER = "abcd2345"
_OTHER_USER = "efgh6789"
_RAW_KEY = "sk-live-key-abcd1234"
def _secrets_with(values: dict[str, str], tmp_path: Path) -> EnvFileSecretsProvider:
env_file = tmp_path / ".env.secrets"
env_file.write_text("".join(f"{k}={v}\n" for k, v in values.items()), encoding="utf-8")
return EnvFileSecretsProvider(paths=[env_file])
def _enabled_store(tmp_path: Path) -> KeyStore:
return KeyStore(_secrets_with({KEK_NAME: _KEK_B64}, tmp_path), tmp_path)
# ---------------------------------------------------------------- KEK decode
def test_kek_decode_b64(tmp_path: Path) -> None:
secrets = _secrets_with({KEK_NAME: _KEK_B64}, tmp_path)
assert load_kek_bytes(secrets) == _KEK_RAW
def test_kek_decode_hex(tmp_path: Path) -> None:
secrets = _secrets_with({KEK_NAME: _KEK_HEX}, tmp_path)
assert load_kek_bytes(secrets) == _KEK_RAW
def test_kek_wrong_length_raises(tmp_path: Path) -> None:
short = base64.urlsafe_b64encode(b"only-16-bytes!!").decode()
with pytest.raises(KeyStoreError) as exc_info:
load_kek_bytes(_secrets_with({KEK_NAME: short}, tmp_path))
assert "32 bytes" in str(exc_info.value)
assert exc_info.value.kind == "bad_kek"
def test_kek_garbage_raises(tmp_path: Path) -> None:
with pytest.raises(KeyStoreError) as exc_info:
load_kek_bytes(_secrets_with({KEK_NAME: "!!!not-base64!!!"}, tmp_path))
assert exc_info.value.kind == "bad_kek"
def test_kek_absent_is_none_not_error(tmp_path: Path) -> None:
assert load_kek_bytes(_secrets_with({}, tmp_path)) is None
def test_bad_kek_disables_store_honestly(tmp_path: Path) -> None:
"""A malformed KEK is not a crash and not a silent enable."""
store = KeyStore(_secrets_with({KEK_NAME: "garbage-value"}, tmp_path), tmp_path)
assert store.enabled is False
assert store.disabled_reason is not None
assert "misconfigured" in store.disabled_reason
# ------------------------------------------------------------- round trips
async def test_set_get_round_trip(tmp_path: Path) -> None:
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
assert await store.get_key(_USER) == _RAW_KEY
async def test_get_without_set_is_none(tmp_path: Path) -> None:
store = _enabled_store(tmp_path)
assert await store.get_key(_USER) is None
async def test_set_overwrites_previous_key(tmp_path: Path) -> None:
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, "first-key-value")
await store.set_key(_USER, KEY_SCOPE, "second-key-value")
assert await store.get_key(_USER) == "second-key-value"
async def test_envelope_shape_on_disk(tmp_path: Path) -> None:
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
assert payload["v"] == 1
assert payload["alg"] == "AES-256-GCM"
assert "updated_at" in payload
entry = payload["entries"][KEY_SCOPE]
assert set(entry) == {"nonce", "ct", "updated_at"}
# Raw key material must not appear anywhere in the file.
assert _RAW_KEY not in json.dumps(payload)
# -------------------------------------------------------------------- AAD
async def test_aad_tamper_copy_to_other_user_fails(tmp_path: Path) -> None:
"""The AAD is user_id: a file copied under another user's id FAILS
decrypt with a typed error — never returns the other user's key."""
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
keys_dir = tmp_path / "keys"
(keys_dir / f"{_OTHER_USER}.json").write_text(
(keys_dir / f"{_USER}.json").read_text(encoding="utf-8"),
encoding="utf-8",
)
with pytest.raises(KeyStoreError) as exc_info:
await store.get_key(_OTHER_USER)
assert exc_info.value.kind == "tampered"
# Never the wrong user's key:
assert exc_info.value.args[0] != _RAW_KEY
async def test_aad_tamper_masked_status_also_fails(tmp_path: Path) -> None:
"""Status must not silently report configured=False for a tampered
envelope — that would be a silent tier drop."""
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
keys_dir = tmp_path / "keys"
(keys_dir / f"{_OTHER_USER}.json").write_text(
(keys_dir / f"{_USER}.json").read_text(encoding="utf-8"),
encoding="utf-8",
)
with pytest.raises(KeyStoreError):
await store.masked_status(_OTHER_USER)
async def test_kek_change_surfaces_as_tamper(tmp_path: Path) -> None:
"""A re-provisioned KEK cannot read old envelopes — typed error,
never silent None (documented loss posture, G-105)."""
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
new_kek = base64.urlsafe_b64encode(os.urandom(32)).decode()
rotated = KeyStore(_secrets_with({KEK_NAME: new_kek}, tmp_path), tmp_path)
with pytest.raises(KeyStoreError) as exc_info:
await rotated.get_key(_USER)
assert exc_info.value.kind == "tampered"
# ------------------------------------------------------------------ nonces
async def test_nonce_unique_across_writes(tmp_path: Path) -> None:
store = _enabled_store(tmp_path)
nonces = []
for i in range(3):
await store.set_key(_USER, KEY_SCOPE, f"key-number-{i}")
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
nonces.append(payload["entries"][KEY_SCOPE]["nonce"])
assert len(set(nonces)) == 3 # a GCM nonce must NEVER repeat under a KEK
async def test_nonce_is_12_bytes(tmp_path: Path) -> None:
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
nonce_b64 = payload["entries"][KEY_SCOPE]["nonce"]
assert len(base64.urlsafe_b64decode(nonce_b64 + "===")) == 12
# ------------------------------------------------------------ masked status
async def test_masked_status_never_returns_raw_key(tmp_path: Path) -> None:
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
status = await store.masked_status(_USER)
assert status["configured"] is True
assert status["masked"].endswith(_RAW_KEY[-4:])
assert _RAW_KEY not in json.dumps(status)
assert status["masked"].startswith("…"[:1]) or status["masked"][0] in ("…", ".")
async def test_masked_status_unconfigured(tmp_path: Path) -> None:
store = _enabled_store(tmp_path)
status = await store.masked_status(_USER)
assert status["configured"] is False
assert status["masked"] is None
# -------------------------------------------------------- disabled honesty
async def test_disabled_store_set_rejected_honestly(tmp_path: Path) -> None:
store = KeyStore(_secrets_with({}, tmp_path), tmp_path) # no KEK
assert store.enabled is False
with pytest.raises(KeyStoreError) as exc_info:
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
assert exc_info.value.kind == "disabled"
assert "RESEARCHX_KEK" in str(exc_info.value)
# And nothing was written to disk.
assert not (tmp_path / "keys" / f"{_USER}.json").exists()
async def test_disabled_store_get_returns_none(tmp_path: Path) -> None:
store = KeyStore(_secrets_with({}, tmp_path), tmp_path)
assert await store.get_key(_USER) is None
async def test_disabled_store_status_says_disabled(tmp_path: Path) -> None:
store = KeyStore(_secrets_with({}, tmp_path), tmp_path)
status = await store.masked_status(_USER)
assert status["disabled"] is True
assert "RESEARCHX_KEK" in str(status["reason"])
assert status["configured"] is False
async def test_disabled_store_with_stored_key_surfaces_error(tmp_path: Path) -> None:
"""The loss posture made visible: a KEK vanished after keys were
stored — reads must surface an error state, never silently None
(which would look like 'no key configured')."""
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
gone = KeyStore(_secrets_with({}, tmp_path), tmp_path)
with pytest.raises(KeyStoreError) as exc_info:
await gone.get_key(_USER)
assert exc_info.value.kind == "disabled"
with pytest.raises(KeyStoreError):
await gone.masked_status(_USER)
async def test_unsupported_scope_rejected(tmp_path: Path) -> None:
store = _enabled_store(tmp_path)
with pytest.raises(KeyStoreError):
await store.set_key(_USER, "TAVILY_API_KEY", "should-not-work")
# ------------------------------------------------------------ file perms
async def test_key_file_perms_0600(tmp_path: Path) -> None:
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
mode = (tmp_path / "keys" / f"{_USER}.json").stat().st_mode & 0o777
assert mode == 0o600
def test_key_store_docstring_documents_loss_posture() -> None:
"""G-105: the loss posture must be stated where the keys live."""
import researchx.auth.key_store as key_store_module
doc = key_store_module.__doc__ or ""
assert "KEK-loss" in doc or "unrecoverable" in doc
assert "back up the KEK" in doc
# ------------------------------------------------------------- settings
async def test_settings_defaults_for_fresh_user(tmp_path: Path) -> None:
store = SettingsStore(tmp_path)
settings = await store.get(_USER)
assert settings.planner_model
assert settings.writer_model
assert settings.methodology_defaults == {}
assert settings.appearance == "system"
# GET never writes:
assert not (tmp_path / "settings" / f"{_USER}.json").exists()
async def test_settings_update_round_trip(tmp_path: Path) -> None:
store = SettingsStore(tmp_path)
updated = await store.update(_USER, UserSettings(planner_model="m1", writer_model="m2"))
assert updated.planner_model == "m1"
assert updated.writer_model == "m2"
assert updated.appearance == "system" # untouched field keeps default
reloaded = await store.get(_USER)
assert reloaded.planner_model == "m1"
assert reloaded.methodology_defaults == {}
async def test_settings_partial_update_preserves_other_fields(tmp_path: Path) -> None:
store = SettingsStore(tmp_path)
await store.update(_USER, UserSettings(planner_model="keep-planner", appearance="dark"))
updated = await store.update(_USER, UserSettings(appearance="light"))
assert updated.planner_model == "keep-planner" # survived the partial write
assert updated.appearance == "light"
async def test_settings_methodology_defaults_round_trip(tmp_path: Path) -> None:
store = SettingsStore(tmp_path)
payload = UserSettings(methodology_defaults={"rapid-answer": {"depth": 2}})
await store.update(_USER, payload)
reloaded = await store.get(_USER)
assert reloaded.methodology_defaults == {"rapid-answer": {"depth": 2}}
async def test_settings_update_validates_merged_payload_before_write(tmp_path: Path) -> None:
"""P2-verify P0 regression: the store never writes a payload it
cannot read back — a null field that slipped past model_copy (which
skips validation) is refused before touching disk."""
store = SettingsStore(tmp_path)
await store.update(_USER, UserSettings(planner_model="keep-me"))
poisoned = await store.get(_USER)
poisoned = poisoned.model_copy(update={"planner_model": None})
with pytest.raises(ValueError):
await store.update(_USER, poisoned)
reloaded = await store.get(_USER)
assert reloaded.planner_model == "keep-me" # on-disk payload untouched
async def test_settings_isolated_per_user(tmp_path: Path) -> None:
store = SettingsStore(tmp_path)
await store.update(_USER, UserSettings(appearance="dark"))
other = await store.get(_OTHER_USER)
assert other.appearance == "system" # fresh defaults, no bleed
assert not (tmp_path / "settings" / f"{_OTHER_USER}.json").exists()
async def test_settings_corrupt_payload_surfaces_typed_error(tmp_path: Path) -> None:
store = SettingsStore(tmp_path)
settings_dir = tmp_path / "settings"
settings_dir.mkdir(parents=True, exist_ok=True)
(settings_dir / f"{_USER}.json").write_text("{not json", encoding="utf-8")
from researchx.auth.settings import SettingsStoreError
with pytest.raises((SettingsStoreError, ValueError)):
await store.get(_USER)
# ================================================== T-2.8 adversarial extension
# (security-engineer hat — corrupt-entry surfacing, concurrent-set
# atomicity, and the opt-in keyed live tier. See STEP 4.)
async def test_corrupt_envelope_surfaces_typed_error_never_silent_drop(tmp_path: Path) -> None:
"""Garbage in keys/<uid>.json → KeyStoreError('unreadable'), never
a silent None (which would silently drop the user's tier)."""
store = _enabled_store(tmp_path)
keys_dir = tmp_path / "keys"
keys_dir.mkdir(parents=True, exist_ok=True)
(keys_dir / f"{_USER}.json").write_text("this is not json at all", encoding="utf-8")
with pytest.raises(KeyStoreError) as exc_info:
await store.get_key(_USER)
assert exc_info.value.kind == "unreadable"
async def test_corrupt_entry_fields_surface_typed_error(tmp_path: Path) -> None:
"""Structurally-valid JSON with missing/invalid fields — typed error."""
store = _enabled_store(tmp_path)
keys_dir = tmp_path / "keys"
keys_dir.mkdir(parents=True, exist_ok=True)
(keys_dir / f"{_USER}.json").write_text(
json.dumps({"v": 1, "alg": "AES-256-GCM", "entries": {"OLLAMA_API_KEY": {"nonce": "xx"}}}),
encoding="utf-8",
)
with pytest.raises(KeyStoreError):
await store.get_key(_USER)
async def test_bit_rot_ciphertext_surfaces_tamper(tmp_path: Path) -> None:
"""Flipping ciphertext bytes → InvalidTag → typed 'tampered'."""
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
path = tmp_path / "keys" / f"{_USER}.json"
payload = json.loads(path.read_text(encoding="utf-8"))
ct = base64.urlsafe_b64decode(payload["entries"][KEY_SCOPE]["ct"] + "===")
flipped = bytes([ct[0] ^ 0xFF]) + ct[1:]
payload["entries"][KEY_SCOPE]["ct"] = base64.urlsafe_b64encode(flipped).decode().rstrip("=")
path.write_text(json.dumps(payload), encoding="utf-8")
with pytest.raises(KeyStoreError) as exc_info:
await store.get_key(_USER)
assert exc_info.value.kind == "tampered"
async def test_concurrent_set_key_never_tears_the_file(tmp_path: Path) -> None:
"""Concurrent writes to the same user: every reader (including a
second KeyStore handle over the same root) sees a valid envelope —
the tmp+replace idiom leaves no torn files."""
store = _enabled_store(tmp_path)
await asyncio.gather(
*(store.set_key(_USER, KEY_SCOPE, f"concurrent-key-{i}") for i in range(16))
)
# The final file is one of the written keys, decryptable, well-formed.
final = await store.get_key(_USER)
assert final is not None
assert final.startswith("concurrent-key-")
# A second handle (fresh process view) reads the same valid file.
second = KeyStore(_secrets_with({KEK_NAME: _KEK_B64}, tmp_path), tmp_path)
again = await second.get_key(_USER)
assert again == final
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
assert payload["v"] == 1 # full envelope, never a partial write
# No tmp strays left behind.
assert [p.name for p in (tmp_path / "keys").iterdir() if p.name.endswith(".tmp")] == []
async def test_set_key_atomic_under_concurrent_reads(tmp_path: Path) -> None:
"""Reads racing a write never observe a torn file."""
store = _enabled_store(tmp_path)
await store.set_key(_USER, KEY_SCOPE, "initial-key")
async def _reader() -> str | None:
for _ in range(20):
try:
key = await store.get_key(_USER)
if key is None:
raise AssertionError("envelope vanished mid-race")
except KeyStoreError as exc:
raise AssertionError(f"torn read surfaced: {exc}") from exc
return None
async def _writer() -> None:
for i in range(10):
await store.set_key(_USER, KEY_SCOPE, f"racing-key-{i}")
await asyncio.gather(_reader(), _writer())
assert (await store.get_key(_USER)).startswith("racing-key-")
def test_keyed_live_tier_marker_registered() -> None:
"""The opt-in keyed live tier is marked `live` and skipped by
default (same pattern as test_live_smoke.py): the marker must be
registered in the pytest config so the opt-in test below skips
cleanly in zero-key CI."""
import tomllib
config = tomllib.loads((Path(__file__).resolve().parents[1] / "pyproject.toml").read_text())
markers = config["tool"]["pytest"]["ini_options"]["markers"]
assert any(str(m).startswith("live:") for m in markers)
# ------------------------------------------------------- opt-in live tier
@pytest.mark.live
async def test_live_keyed_tier_end_to_end(tmp_path: Path) -> None:
"""Opt-in keyed tier (skipped by default, like test_live_smoke):
with a real RESEARCHX_KEK + OLLAMA_API_KEY in the secrets scope, a
keyed user gets real inference through the REQ-74 chain."""
secrets_paths = [
Path.cwd() / ".ciagent" / ".env.secrets",
Path.cwd().parents[1] / ".ciagent" / ".env.secrets",
]
env = EnvFileSecretsProvider(paths=secrets_paths)
kek = env.resolve(KEK_NAME)
model_key = env.resolve("OLLAMA_API_KEY")
if not kek or not model_key:
pytest.skip("RESEARCHX_KEK/OLLAMA_API_KEY not present — live tier is opt-in")
store = KeyStore(env, tmp_path)
assert store.enabled, "with a configured KEK the store must be enabled"
await store.set_key("live0001", KEY_SCOPE, model_key)
resolved = await store.get_key("live0001")
assert resolved == model_key
+687
View File
@@ -0,0 +1,687 @@
"""Live-surface route tests (T-3.3, REQ-76..82).
Every new/reworked route over a fixture disk tree: report/ledger
disk-read with pre-artifact DONE fallback, library/connections/graph/
tasks/suggestions, refine (200 on fakes, honest 503 on adapter
failure), profile round-trip, and the FRESH-APP-OVER-EXISTING-DATA-
ROOT test (P-8 full closure — every read route correct against a
second create_app with zero in-memory actors).
"""
import asyncio
import json
from pathlib import Path
import httpx
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
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 = "# Research Memo\n\nClaim [p-1] and claim [p-2].\n"
_HEADERS = {CSRF_HEADER: "1"}
_DEV = "dev00000" # the synthetic dev admin (auth_enabled=False harness)
def _state(run_id: str, question: str, state: str, created_at: str, plan: dict | None = None) -> dict:
return {
"run_id": run_id,
"question": question,
"context": "",
"methodology_slug": "deep-report",
"state": state,
"owner_id": _DEV,
"plan": plan if plan is not None else {
"sub_questions": ["battery density question"],
"source_universe": ["web"],
"outline": ["intro"],
},
"depth_cursor": 0,
"depth_target": 1,
"counters": {},
"probe": None,
"created_at": created_at,
"updated_at": created_at,
}
def _seed_run(
tmp_path: Path,
run_id: str,
*,
state: str,
question: str = "What about battery research?",
created_at: str = "2026-09-20T10:00:00Z",
plan: dict | None = None,
report_markdown: str | None = None,
report_json: dict | None = None,
evidence_json: dict | None = None,
ledger_json: dict | None = None,
owner: str = _DEV,
) -> None:
run_dir = tmp_path / "runs" / owner / run_id
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "state.json").write_text(
json.dumps(_state(run_id, question, state, created_at, plan)), encoding="utf-8"
)
if report_markdown is not None:
(run_dir / "report.md").write_text(report_markdown, encoding="utf-8")
if report_json is not None:
(run_dir / "report.json").write_text(json.dumps(report_json), encoding="utf-8")
if evidence_json is not None:
(run_dir / "evidence.json").write_text(json.dumps(evidence_json), encoding="utf-8")
if ledger_json is not None:
(run_dir / "ledger.json").write_text(json.dumps(ledger_json), encoding="utf-8")
_REPORT_JSON = {
"v": 1,
"run_id": "runaaaa1",
"owner_id": _DEV,
"title": "Battery Advances",
"question": "What about battery research?",
"methodology_slug": "deep-report",
"generated_at": "2026-09-20T10:05:00Z",
"sections": [
{"heading": "Research Memo", "body": "The verified core finding [^p1]."}
],
"citations": [
{
"passage_id": "p1",
"url": "https://example.org/x",
"title": "Verification passes",
"quote": "The verified core finding.",
"health": "live",
"authority_tier": "reference",
}
],
}
_EVIDENCE_JSON = {
"v": 1,
"run_id": "runaaaa1",
"passages": [
{
"passage_id": "p1",
"url": "https://example.org/x",
"title": "Verification passes",
"quote": "The verified core finding.",
"text": "The verified core finding in full, stored at DONE.",
}
],
}
_LEDGER_JSON = {
"run_id": "runaaaa1",
"generated_at": "2026-09-20T10:05:00Z",
"entries": [
{
"url": "https://example.org/x",
"visited_at": "2026-09-20T10:04:00Z",
"passages": ["p-1"],
"claims_supported": 2,
"health": "live",
"authority_tier": "reference",
}
],
"checklist": {"run_id": "runaaaa1", "citations_checked": 1},
}
def _seed_tree(tmp_path: Path) -> Path:
"""The route fixture tree under the dev owner:
- runaaaa1: DONE with report.md + report.json + evidence.json + ledger
- runbbbb1: DONE pre-artifact (report.md only — no report.json)
- runcccc1: non-terminal (SEARCHING) — swept to CANCELLED by create_app
"""
_seed_run(
tmp_path,
"runaaaa1",
state="DONE",
report_markdown="# Research Memo\n\nThe verified core finding [p-1].\n",
report_json=_REPORT_JSON,
evidence_json=_EVIDENCE_JSON,
ledger_json=_LEDGER_JSON,
)
_seed_run(
tmp_path,
"runbbbb1",
state="DONE",
question="A pre-artifact done run?",
created_at="2026-09-19T10:00:00Z",
report_markdown="# Old Report\n\nRaw [p-1] markers only.\n",
)
_seed_run(
tmp_path,
"runcccc1",
state="SEARCHING",
question="Swept mid-flight question?",
created_at="2026-09-21T10:00:00Z",
)
return tmp_path
def _fake_app(tmp_path: Path, canned: list[ChatResult] | None = None) -> object:
return create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(canned=canned or [ChatResult(content=_PLAN, model="p")]),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async def _client(app: object) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
base_url="http://testserver",
)
# --------------------------------------------------------------- report route
async def test_report_route_disk_read_returns_full_payload(tmp_path: Path) -> None:
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
response = await client.get("/api/runs/runaaaa1/report")
assert response.status_code == 200
body = response.json()
assert body["run_id"] == "runaaaa1"
assert "[p-1]" in body["markdown"] # raw form retained (back-compat)
assert body["report"] == _REPORT_JSON
assert body["evidence"] == _EVIDENCE_JSON
async def test_report_route_pre_artifact_done_fallback(tmp_path: Path) -> None:
"""A DONE run from before T-3.1 (report.md only): markdown served,
null report/evidence — never a 500 (D-106)."""
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
response = await client.get("/api/runs/runbbbb1/report")
assert response.status_code == 200
body = response.json()
assert body["run_id"] == "runbbbb1"
assert "Raw [p-1] markers" in body["markdown"]
assert body["report"] is None
assert body["evidence"] is None
async def test_report_route_missing_run_404(tmp_path: Path) -> None:
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
assert (await client.get("/api/runs/zzzz9999/report")).status_code == 404
async def test_ledger_route_disk_read(tmp_path: Path) -> None:
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
response = await client.get("/api/runs/runaaaa1/ledger")
assert response.status_code == 200
assert response.json() == _LEDGER_JSON
assert (await client.get("/api/runs/zzzz9999/ledger")).status_code == 404
# ------------------------------------------------------------ run list route
async def test_runs_route_lists_from_disk_recent_first(tmp_path: Path) -> None:
"""GET /api/runs from disk state files; the SEARCHING fixture is
swept to CANCELLED at create_app — it surfaces cancelled, NOT open
(post-sweep honesty)."""
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
response = await client.get("/api/runs")
assert response.status_code == 200
runs = response.json()
assert [run["id"] for run in runs] == ["runcccc1", "runaaaa1", "runbbbb1"]
statuses = {run["id"]: run["status"] for run in runs}
assert statuses["runcccc1"] == "cancelled" # swept, honest
assert statuses["runaaaa1"] == "done"
assert statuses["runbbbb1"] == "done"
first = runs[1]
assert first["question"] == "What about battery research?"
assert first["methodology"] == "deep-report"
assert first["plan"]["sub_questions"] == ["battery density question"]
# ----------------------------------------------------------- library routes
async def test_library_route(tmp_path: Path) -> None:
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
response = await client.get("/api/library")
assert response.status_code == 200
body = response.json()
assert [folder["id"] for folder in body["folders"]] == ["deep-report-battery"]
assert [report["runId"] for report in body["reports"]] == ["runaaaa1", "runbbbb1"]
first = body["reports"][0]
assert first["title"] == "Battery Advances"
assert first["citationCount"] == 1
assert first["category"] == "deep-report-battery"
# title sort: "Battery Advances" < "Deep Report — A pre-artifact..."
titled = await client.get("/api/library?sort=title")
assert [r["runId"] for r in titled.json()["reports"]] == ["runaaaa1", "runbbbb1"]
async def test_connections_graph_tasks_suggestions_routes(tmp_path: Path) -> None:
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
connections = await client.get("/api/library/connections")
assert connections.status_code == 200
# runaaaa1 has a ledger; runbbbb1 has none -> no shared URL, and
# same methodology+category -> related-topic edge.
assert connections.json() == [
{
"fromReportId": "rep-runbbbb1",
"toReportId": "rep-runaaaa1",
"kind": "related-topic",
"label": "Adjacent topic",
}
]
graph = await client.get("/api/graph")
assert graph.status_code == 200
payload = graph.json()
assert {node["id"] for node in payload["nodes"]} == {"runaaaa1", "runbbbb1"}
node = next(n for n in payload["nodes"] if n["id"] == "runaaaa1")
assert node["topic"] == "Battery Advances"
assert node["linkedSources"] == ["https://example.org/x"]
assert payload["edges"] == [{"fromId": "runbbbb1", "toId": "runaaaa1", "sharedSourceCount": 0}]
tasks = await client.get("/api/tasks")
assert tasks.status_code == 200
items = {item["id"]: item for item in tasks.json()}
assert items["runaaaa1#0"]["status"] == "done"
assert items["runbbbb1#0"]["status"] == "done"
# The swept CANCELLED run's question stays OPEN (D-114).
assert items["runcccc1#0"]["status"] == "open"
assert items["runcccc1#0"]["question"] == "battery density question"
suggestions = await client.get("/api/suggestions")
assert suggestions.status_code == 200
assert suggestions.json() == [
{
"id": "sug-runcccc1#0",
"question": "battery density question",
"sourceRunId": "runcccc1",
"sourceReportTitle": "Deep Report — Swept mid-flight question?",
}
]
async def test_fresh_user_empty_states(tmp_path: Path) -> None:
"""Honest empty states: no synthetic content anywhere."""
async with await _client(_fake_app(tmp_path)) as client:
assert (await client.get("/api/library")).json() == {"folders": [], "reports": []}
assert (await client.get("/api/library/connections")).json() == []
assert (await client.get("/api/graph")).json() == {"nodes": [], "edges": []}
assert (await client.get("/api/tasks")).json() == []
assert (await client.get("/api/suggestions")).json() == []
assert (await client.get("/api/runs")).json() == []
# ---------------------------------------------------------------- refine
class _RefineModel:
"""Fake model for the refine exchange: answers the questions prompt,
then the compose prompt; a second instance answers with an
AdapterError to pin the honest 503."""
def __init__(self, fail: bool = False) -> None:
self._fail = fail
self._calls = 0
async def chat(
self,
messages: list[ModelMessage],
*,
model: str,
temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult:
del messages, temperature, model
if self._fail:
raise AdapterError("no local model reachable")
self._calls += 1
if self._calls == 1:
return ChatResult(
content=json.dumps(
{
"questions": [
"What scope matters most?",
"What decision will this inform?",
"How deep should the answer go?",
]
}
),
model="fake-refine",
)
return ChatResult(
content=json.dumps(
{
"question": "How do batteries work — focusing on density?",
"context": "Feeds a supplier decision. Depth: executive summary.",
}
),
model="fake-refine",
)
async def list_models(self) -> list[str]:
return ["fake-refine"]
def roles(self) -> object:
from researchx.adapters.model import ModelRoles
return ModelRoles(planner="fake-refine", writer="fake-refine")
async def test_refine_questions_and_compose(tmp_path: Path) -> None:
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with await _client(app) as client:
asked = await client.post("/api/refine/questions", json={"draft": "how do batteries work?"})
assert asked.status_code == 200
assert asked.json() == {
"questions": [
"What scope matters most?",
"What decision will this inform?",
"How deep should the answer go?",
]
}
composed = await client.post(
"/api/refine/compose",
json={"draft": "how do batteries work?", "answers": ["density", "supplier choice"]},
)
assert composed.status_code == 200
assert composed.json() == {
"question": "How do batteries work — focusing on density?",
"context": "Feeds a supplier decision. Depth: executive summary.",
}
async def test_refine_honest_503_never_hang(tmp_path: Path) -> None:
"""A keyless user with no reachable endpoint gets an honest 503 —
never a hang, never a silent success."""
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(fail=True),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with await _client(app) as client:
asked = await asyncio.wait_for(
client.post("/api/refine/questions", json={"draft": "q"}), timeout=5.0
)
assert asked.status_code == 503
composed = await asyncio.wait_for(
client.post(
"/api/refine/compose", json={"draft": "q", "answers": ["a"]}, headers=_HEADERS
),
timeout=5.0,
)
assert composed.status_code == 503
async def test_refine_requires_csrf_header_when_authed(tmp_path: Path) -> None:
"""Refine POSTs are mutations: the CSRF header applies (T-3.6)."""
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# register + login (both with the header)
await client.post(
"/api/auth/register",
json={"email": "a@example.org", "password": "long-enough-pass", "display_name": "A"},
headers=_HEADERS,
)
await client.post(
"/api/auth/login", json={"email": "a@example.org", "password": "long-enough-pass"}, headers=_HEADERS
)
no_header = await client.post("/api/refine/questions", json={"draft": "q"})
assert no_header.status_code == 403
with_header = await client.post("/api/refine/questions", json={"draft": "q"}, headers=_HEADERS)
assert with_header.status_code == 200
# ---------------------------------------------------------------- profile
async def test_profile_round_trip(tmp_path: Path) -> None:
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
await client.post(
"/api/auth/register",
json={"email": "first@example.org", "password": "long-enough-pass", "display_name": "First"},
headers=_HEADERS,
)
await client.post(
"/api/auth/login",
json={"email": "first@example.org", "password": "long-enough-pass"},
headers=_HEADERS,
)
got = await client.get("/api/profile")
assert got.status_code == 200
assert got.json() == {
"user_id": got.json()["user_id"],
"display_name": "First",
"email": "first@example.org",
}
put = await client.put(
"/api/profile", json={"display_name": "Renamed", "email": "new@example.org"}, headers=_HEADERS
)
assert put.status_code == 200
assert put.json()["display_name"] == "Renamed"
assert put.json()["email"] == "new@example.org"
again = await client.get("/api/profile")
assert again.json() == put.json()
async def test_profile_email_unique_across_users(tmp_path: Path) -> None:
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await admin.post(
"/api/auth/register",
json={"email": "admin@example.org", "password": "long-enough-pass", "display_name": "A"},
headers=_HEADERS,
)
await admin.post(
"/api/auth/login", json={"email": "admin@example.org", "password": "long-enough-pass"}, headers=_HEADERS
)
created = await admin.post(
"/api/admin/users",
json={"email": "second@example.org", "password": "long-enough-pass", "role": "user"},
headers=_HEADERS,
)
assert created.status_code == 201
# admin tries to steal the second user's email
taken = await admin.put(
"/api/profile", json={"email": "second@example.org"}, headers=_HEADERS
)
assert taken.status_code == 409
# ---------------------------------------------- cross-user isolation (authed)
async def test_cross_user_404_on_run_scoped_routes(tmp_path: Path) -> None:
"""Owner-scoped disk reads: user 2 requesting user 1's run gets a
404 (no existence leak) — the owner dir simply doesn't exist under
user 2's storage view."""
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await admin.post(
"/api/auth/register",
json={"email": "admin@example.org", "password": "long-enough-pass", "display_name": "A"},
headers=_HEADERS,
)
await admin.post(
"/api/auth/login", json={"email": "admin@example.org", "password": "long-enough-pass"}, headers=_HEADERS
)
admin_id = (await admin.get("/api/auth/me")).json()["user_id"]
_seed_run(
tmp_path,
"runaaaa1",
state="DONE",
report_markdown="# Mine\n\n[p-1]\n",
report_json=_REPORT_JSON,
evidence_json=_EVIDENCE_JSON,
ledger_json=_LEDGER_JSON,
owner=admin_id,
)
await admin.post(
"/api/admin/users",
json={"email": "second@example.org", "password": "long-enough-pass", "role": "user"},
headers=_HEADERS,
)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
await second.post(
"/api/auth/login", json={"email": "second@example.org", "password": "long-enough-pass"}, headers=_HEADERS
)
assert (await second.get("/api/runs/runaaaa1/report")).status_code == 404
assert (await second.get("/api/runs/runaaaa1/ledger")).status_code == 404
# The owner-scoped collections are simply empty for user 2.
assert (await second.get("/api/runs")).json() == []
assert (await second.get("/api/library")).json() == {"folders": [], "reports": []}
assert (await second.get("/api/tasks")).json() == []
assert (await second.get("/api/suggestions")).json() == []
# ------------------------------------- P-8 full closure: fresh app, same root
async def test_full_run_over_http_writes_and_serves_artifacts(tmp_path: Path) -> None:
"""End-to-end marker contract through the API: a run driven to DONE
persists report.json/evidence.json (T-3.1), and the reworked report
route serves normalized [^pN] bodies while report.md (via the
markdown field) keeps raw [p-N]."""
app = _fake_app(
tmp_path,
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER, model="fake-writer"),
],
)
async with await _client(app) as client:
created = await client.post(
"/api/runs", json={"question": "How do engines run?", "methodology": "rapid-answer"}
)
assert created.status_code == 201
run_id = created.json()["id"]
for _ in range(400): # await AWAITING_APPROVAL
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")
for _ in range(400): # await DONE
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "done":
break
await asyncio.sleep(0.01)
assert status == "done"
report = (await client.get(f"/api/runs/{run_id}/report")).json()
assert report["report"] is not None
assert report["evidence"] is not None
# C3-10 through the API: bodies normalized, markdown raw.
joined = "\n".join(str(s["body"]) for s in report["report"]["sections"])
assert "[^p1]" in joined or "[^p2]" in joined
assert "[p-1]" not in joined
assert "[p-1]" in report["markdown"]
# citations + evidence ids are pN-form with real quotes.
for citation in report["report"]["citations"]:
assert citation["passage_id"].startswith("p")
assert "-" not in citation["passage_id"]
assert citation["quote"]
passages = report["evidence"]["passages"]
assert passages, "evidence must carry the full stored passages"
assert all(len(str(p["text"])) >= len(str(p["quote"])) for p in passages)
# The disk read-models see the run too (library/tasks derive).
library = (await client.get("/api/library")).json()
assert [r["runId"] for r in library["reports"]] == [run_id]
tasks = (await client.get("/api/tasks")).json()
assert all(item["status"] == "done" for item in tasks)
async def test_fresh_app_over_existing_data_root(tmp_path: Path) -> None:
"""P-8 full closure: a SECOND create_app over the same data root —
zero in-memory actors/registry — every read route still correct.
(The registry-independence proof; the first app's actor state is
unreachable by construction here.)"""
_seed_tree(tmp_path)
first = _fake_app(tmp_path)
del first # the first app instance is discarded; nothing carries over
second = create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(canned=[ChatResult(content=_PLAN, model="p")]),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with await _client(second) as client:
# report: full payload, disk-read
report = (await client.get("/api/runs/runaaaa1/report")).json()
assert report["report"] == _REPORT_JSON
assert report["evidence"] == _EVIDENCE_JSON
assert "[p-1]" in report["markdown"]
# pre-artifact fallback survives the restart too
legacy = (await client.get("/api/runs/runbbbb1/report")).json()
assert legacy["report"] is None and legacy["markdown"]
# ledger
assert (await client.get("/api/runs/runaaaa1/ledger")).json() == _LEDGER_JSON
# run list: swept CANCELLED is cancelled, not open
statuses = {run["id"]: run["status"] for run in (await client.get("/api/runs")).json()}
assert statuses["runcccc1"] == "cancelled"
# library + connections + graph + tasks + suggestions
library = (await client.get("/api/library")).json()
assert [r["runId"] for r in library["reports"]] == ["runaaaa1", "runbbbb1"]
assert (await client.get("/api/library/connections")).status_code == 200
assert (await client.get("/api/graph")).status_code == 200
tasks = {item["id"]: item for item in (await client.get("/api/tasks")).json()}
assert tasks["runcccc1#0"]["status"] == "open"
assert (await client.get("/api/suggestions")).status_code == 200
# profile (dev harness GET works with zero stored runs)
assert (await client.get("/api/profile")).status_code == 200
+70 -10
View File
@@ -4,9 +4,15 @@ from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.storage import StorageAdapter
from researchx.adapters.types import AdapterError
_OWNER = "u1a2b3c4"
def _storage(tmp_path: Path) -> LocalStorageAdapter:
return LocalStorageAdapter(base_dir=tmp_path, owner_id=_OWNER)
async def test_storage_round_trip(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
assert isinstance(storage, StorageAdapter)
target = await storage.write_run_artifact("abcd2345", "state.json", '{"state": "PLANNING"}')
assert target.exists()
@@ -15,24 +21,52 @@ async def test_storage_round_trip(tmp_path: Path) -> None:
async def test_atomic_overwrite(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 2}')
assert await storage.read_run_artifact("abcd2345", "state.json") == '{"v": 2}'
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
leftovers = list((tmp_path / "runs" / _OWNER / "abcd2345").glob("*.tmp"))
assert leftovers == []
async def test_run_dir_layout(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
run_dir = storage.run_dir("abcd2345")
assert run_dir == tmp_path / "runs" / "abcd2345"
# D-106: runs/<owner_id>/<run_id>/ partitioning (supersedes D-010).
assert run_dir == tmp_path / "runs" / _OWNER / "abcd2345"
await storage.write_run_artifact("abcd2345", "report.md", "# Report")
assert run_dir.is_dir()
async def test_owner_id_required_and_validated(tmp_path: Path) -> None:
for bad_owner in ["", "short", "waytoolongid123", "ABCD2345", "a b c d e", "../evil"]:
try:
LocalStorageAdapter(base_dir=tmp_path, owner_id=bad_owner)
except AdapterError:
pass
else:
raise AssertionError(f"expected AdapterError for owner_id: {bad_owner!r}")
# owner_id is required — no default partition key.
try:
LocalStorageAdapter(base_dir=tmp_path) # type: ignore[call-arg]
except TypeError:
pass
else:
raise AssertionError("owner_id must be a required positional parameter")
async def test_read_missing_error_embeds_owner_path(tmp_path: Path) -> None:
storage = _storage(tmp_path)
try:
await storage.read_run_artifact("zzzz9999", "state.json")
except AdapterError as exc:
assert f"runs/{_OWNER}/zzzz9999/state.json" in str(exc)
else:
raise AssertionError("expected AdapterError")
async def test_read_missing_raises(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
try:
await storage.read_run_artifact("zzzz9999", "state.json")
except AdapterError:
@@ -42,7 +76,7 @@ async def test_read_missing_raises(tmp_path: Path) -> None:
async def test_path_traversal_rejected(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
for bad_name in ["../escape.md", "/etc/passwd", "a/b.md", "", "."]:
try:
await storage.write_run_artifact("abcd2345", bad_name, "x")
@@ -60,7 +94,33 @@ async def test_path_traversal_rejected(tmp_path: Path) -> None:
async def test_no_tmp_leftover_after_write(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
assert leftovers == []
leftovers = list((tmp_path / "runs" / _OWNER / "abcd2345").glob("*.tmp"))
assert leftovers == []
async def test_partition_isolation_between_owners(tmp_path: Path) -> None:
alice = LocalStorageAdapter(base_dir=tmp_path, owner_id="aaaa1111")
bob = LocalStorageAdapter(base_dir=tmp_path, owner_id="bbbb2222")
await alice.write_run_artifact("abcd2345", "state.json", '{"owner": "alice"}')
try:
await bob.read_run_artifact("abcd2345", "state.json")
except AdapterError:
pass
else:
raise AssertionError("owner partitions must be isolated on disk")
async def test_list_run_ids_scans_owner_partition(tmp_path: Path) -> None:
storage = _storage(tmp_path)
assert storage.list_run_ids() == []
await storage.write_run_artifact("abcd2345", "state.json", "{}")
await storage.write_run_artifact("efgh6789", "state.json", "{}")
# A file (not a dir) and dotfiles are skipped defensively.
(tmp_path / "runs" / _OWNER / "notadir.md").write_text("x")
(tmp_path / "runs" / _OWNER / ".hidden").mkdir()
assert storage.list_run_ids() == ["abcd2345", "efgh6789"]
other = LocalStorageAdapter(base_dir=tmp_path, owner_id="zzzz9999")
assert other.list_run_ids() == [] # separate partition, empty
+312
View File
@@ -0,0 +1,312 @@
"""Production entrypoint suite (T-2.7 — P-3 backend closure).
The live app built by ``researchx.api.main`` must be structurally
incapable of serving fakes:
- The per-run model path is the ModelAdapterFactory — a run executes
against an ``OllamaModelAdapter`` (resolved per user), never
``FakeModelAdapter``. Pinned by asserting the factory resolves the
adapter and that the app instance holds no fake in the model slot.
- ``auth_enabled=True`` hardwired: every business route 401s without a
session; there is no parameter or env that turns auth off.
- ``cookie_secure=True``: the login cookie carries Secure (behind TLS).
- The data root + secrets file come from env vars (RESEARCHX_DATA_DIR,
RESEARCHX_SECRETS_FILE) — the test seam is the natural runtime
config, parameterized not env-gated.
"""
import base64
import json
import os
from pathlib import Path
import httpx
import pytest
from researchx.adapters.fakes import FakeModelAdapter
from researchx.auth.guard import CSRF_HEADER
_HEADERS = {CSRF_HEADER: "1"}
_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode()
_ADMIN_EMAIL = "admin@example.org"
_ADMIN_PASSWORD = "correct-horse-battery"
def _runtime_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, secrets: str | None, data_dir: Path
) -> None:
"""Point the production entry at test-owned locations.
This is the parameterization seam: main.py reads the same env vars
the container runtime passes (RESEARCHX_SECRETS_FILE via --env-file
conventions, RESEARCHX_DATA_DIR for the volume) — no test-only
gates exist in the module.
"""
monkeypatch.setenv("RESEARCHX_DATA_DIR", str(data_dir))
if secrets is None:
monkeypatch.delenv("RESEARCHX_SECRETS_FILE", raising=False)
else:
env_file = tmp_path / "env.secrets"
env_file.write_text(secrets, encoding="utf-8")
monkeypatch.setenv("RESEARCHX_SECRETS_FILE", str(env_file))
def _live_secrets(kek: bool = True, server_key: bool = True) -> str:
lines = []
if kek:
lines.append(f"RESEARCHX_KEK={_KEK}")
if server_key:
lines.append("OLLAMA_API_KEY=server-tier-key-value")
return "\n".join(lines) + "\n"
async def _register_admin(client: httpx.AsyncClient) -> None:
await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
headers=_HEADERS,
)
async def _login_with_secure_cookie(client: httpx.AsyncClient) -> str:
"""Login and return the session cookie header value.
The production cookie carries Secure (hardwired, E-4); httpx's
cookie jar — like a browser over plain http — will not replay a
Secure cookie to a non-TLS origin. The tests therefore forward the
cookie explicitly: the ASGI transport is the TLS proxy's interior
leg, where the Secure flag does not apply.
"""
login = await client.post(
"/api/auth/login",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
headers=_HEADERS,
)
assert login.status_code == 200
return login.headers["set-cookie"].split(";")[0]
def _auth_headers(cookie: str) -> dict[str, str]:
return {**_HEADERS, "cookie": cookie}
# --------------------------------------------------------------- structure
async def test_main_factory_resolves_real_adapters(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The P-3 backend closure: main's factory wiring resolves a real
OllamaModelAdapter per user — the production app can never serve
FakeModelAdapter (no fixed model adapter is even constructed when
a factory is wired; pinned structurally in app.py)."""
from researchx.api import main as main_module
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=tmp_path / "data")
main_module.build_app() # must import + build cleanly
from datetime import UTC, datetime
from researchx.adapters.model_factory import ModelAdapterFactory
from researchx.adapters.ollama import OllamaModelAdapter
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.auth.key_store import KeyStore
from researchx.auth.models import UserPublic
from researchx.auth.settings import UserSettings
secrets_paths = main_module._secrets_paths()
assert secrets_paths, "the runtime env file must be discoverable"
secrets = EnvFileSecretsProvider(paths=secrets_paths)
factory = ModelAdapterFactory(secrets, KeyStore(secrets, tmp_path / "data"))
user = UserPublic(
user_id="abcd2345",
email="u@example.org",
display_name="U",
role="user",
active=True,
created_at=datetime.now(UTC),
)
adapter, tier = await factory.resolve(user, UserSettings())
assert isinstance(adapter, OllamaModelAdapter)
assert not isinstance(adapter, FakeModelAdapter)
assert tier in ("user", "server", "keyless")
# --------------------------------------------------------------- behavior
async def test_main_app_auth_hardwired(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""No env, no parameter: every business route 401s without a
session (REQ-85 — there is no auth_enabled=False path in main)."""
from researchx.api import main as main_module
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=tmp_path / "data")
app = main_module.build_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
for path in [
"/api/config",
"/api/config/settings",
"/api/config/settings/model-key",
"/api/methodologies",
"/api/runs/abcd2345",
]:
response = await client.get(path)
assert response.status_code == 401, f"{path} leaked without auth"
# POST-only surface rejects unauthenticated POSTs identically.
assert (await client.post("/api/runs", json={}, headers=_HEADERS)).status_code == 401
# The public surface stays public (D-103).
assert (await client.get("/healthz")).status_code == 200
async def test_main_app_cookie_secure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""cookie_secure=True hardwired (E-4): the session cookie carries
Secure because the public surface is behind the TLS proxy."""
from researchx.api import main as main_module
data_dir = tmp_path / "data"
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
app = main_module.build_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
await _register_admin(client)
login = await client.post(
"/api/auth/login",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
headers=_HEADERS,
)
assert login.status_code == 200
set_cookie = login.headers["set-cookie"].lower()
assert "secure" in set_cookie # hardwired behind TLS
async def test_main_app_config_resolves_tier_with_runtime_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""End to end through the production wiring: register + login +
GET /api/config shows the resolved tier and key-store state from
the runtime env file (KEK + server key -> server tier for a
keyless user, key_store enabled)."""
from researchx.api import main as main_module
data_dir = tmp_path / "data"
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
app = main_module.build_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
await _register_admin(client)
cookie = await _login_with_secure_cookie(client)
config = await client.get("/api/config", headers=_auth_headers(cookie))
assert config.status_code == 200
body = config.json()
assert body["resolved_tier"] == "server" # server key present, no user key
assert body["key_store"] == "enabled"
assert body["model_adapter"] == "OllamaModelAdapter" # never the fake
async def test_main_app_keyless_without_keys(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No KEK, no server key: key_store disabled, tier keyless — the
honest zero-key posture through the production wiring."""
from researchx.api import main as main_module
data_dir = tmp_path / "data"
_runtime_env(tmp_path, monkeypatch, secrets="", data_dir=data_dir)
app = main_module.build_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
await _register_admin(client)
cookie = await _login_with_secure_cookie(client)
config = await client.get("/api/config", headers=_auth_headers(cookie))
body = config.json()
assert body["resolved_tier"] == "keyless"
assert body["key_store"] == "disabled"
assert body["key_status"]["disabled"] is True
async def test_main_app_key_store_round_trip_with_kek(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""With a runtime KEK the production app accepts a user key and
resolves tier 'user' afterwards (REQ-73/74 chain end to end)."""
from researchx.api import main as main_module
data_dir = tmp_path / "data"
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
app = main_module.build_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
await _register_admin(client)
cookie = await _login_with_secure_cookie(client)
put = await client.put(
"/api/config/settings/model-key",
json={"key": "user-tier-key-7777"},
headers=_auth_headers(cookie),
)
assert put.status_code == 200
assert put.json()["configured"] is True
config = await client.get("/api/config", headers=_auth_headers(cookie))
body = config.json()
assert body["resolved_tier"] == "user"
assert body["key_status"]["masked"].endswith("7777")
# The key file lands under the DATA ROOT (not cwd).
me = await client.get("/api/auth/me", headers=_auth_headers(cookie))
user_id = me.json()["user_id"]
key_file = data_dir / "keys" / f"{user_id}.json"
assert key_file.is_file()
payload = json.loads(key_file.read_text(encoding="utf-8"))
assert "user-tier-key-7777" not in json.dumps(payload) # encrypted at rest
async def test_main_app_storage_is_data_root(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The config status storage_dir is the data-root string (D-107) —
never cwd-dependent."""
from researchx.api import main as main_module
data_dir = tmp_path / "data-root"
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
app = main_module.build_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
await _register_admin(client)
cookie = await _login_with_secure_cookie(client)
config = await client.get("/api/config", headers=_auth_headers(cookie))
assert config.json()["storage_dir"] == str(data_dir)
def test_main_module_has_no_auth_disable_surface() -> None:
"""The main module exposes no auth_enabled parameter and reads no
env var that could disable auth (REQ-85 + C3-16)."""
import inspect
from researchx.api import main as main_module
signature = inspect.signature(main_module.build_app)
assert "auth_enabled" not in signature.parameters
assert "cookie_secure" not in signature.parameters
source = inspect.getsource(main_module)
assert "RESEARCHX_AUTH" not in source
assert "auth_enabled=False" not in source
# build_app passes auth_enabled=True exactly once (hardcoded).
build_source = inspect.getsource(main_module.build_app)
assert build_source.count("auth_enabled=") == 1
assert "auth_enabled=True" in build_source
assert "cookie_secure=True" in build_source
+4 -4
View File
@@ -59,7 +59,7 @@ async def test_evidence_reaches_writer_prompt(tmp_path: Path) -> None:
run_config=config,
model=model, # type: ignore[arg-type]
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
@@ -101,7 +101,7 @@ async def test_resume_from_searching_mid_run(tmp_path: Path) -> None:
question="q", context="", methodology_slug="rapid-answer",
overrides={}, registry=registry, run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
mid_state = RunState(
run_id="abcd2345",
question="q",
@@ -148,7 +148,7 @@ async def test_cancel_after_approval_persists_cancelled(tmp_path: Path) -> None:
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
@@ -161,7 +161,7 @@ async def test_cancel_after_approval_persists_cancelled(tmp_path: Path) -> None:
with pytest.raises(asyncio.CancelledError):
await task
saved = json.loads(
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
)
assert saved["state"] == "CANCELLED"
+3 -3
View File
@@ -57,7 +57,7 @@ async def test_probe_attaches_to_run_state(tmp_path: Path, monkeypatch) -> None:
run_config=config,
model=fake_model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
@@ -72,7 +72,7 @@ async def test_probe_attaches_to_run_state(tmp_path: Path, monkeypatch) -> None:
state = await task
assert state.state == RunStateName.DONE
saved = json.loads(
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
)
assert saved["probe"]["endpoint"] == "https://ollama.com/v1"
@@ -116,7 +116,7 @@ async def test_probe_failure_never_crashes_the_run(tmp_path: Path, monkeypatch)
run_config=config,
model=fake_model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=bus,
)
+508
View File
@@ -0,0 +1,508 @@
"""Read-model unit tests (T-3.2, D-114/D-115 — REQ-76..79).
A fixture disk tree — two users; DONE, CANCELLED, FAILED runs; a
dotfile and a .tmp leftover — asserting categorization, sorting,
shared-source/topic edges, tasks answered-iff DONE, suggestions
(never synthetic, empty for fresh users), dotfile/tmp skip, and
cross-user isolation at the read-model level.
"""
import json
from pathlib import Path
from researchx.api.read_models import (
categorize,
connections,
graph,
library,
list_runs,
suggestions,
tasks,
topic_tokens,
)
_U1 = "aaaa1111"
_U2 = "bbbb2222"
_PLAN_A = {
"sub_questions": ["battery energy density gains", "solid-state pilot production"],
"source_universe": ["web"],
"outline": ["intro"],
}
_PLAN_B = {
"sub_questions": ["battery safety testing standards"],
"source_universe": ["web"],
"outline": ["intro"],
}
_PLAN_CANCELLED = {
"sub_questions": ["abandoned open question"],
"source_universe": ["web"],
"outline": ["intro"],
}
_PLAN_FAILED = {"sub_questions": ["failed run question"], "source_universe": ["web"], "outline": []}
_PLAN_INFLIGHT = {"sub_questions": ["still running question"], "source_universe": ["web"], "outline": []}
_REPORT_A = {
"v": 1,
"run_id": "runaaaa1",
"owner_id": _U1,
"title": "Battery Advances",
"question": "What are the latest advances in solid-state batteries?",
"methodology_slug": "deep-report",
"generated_at": "2026-09-20T14:32:00Z",
"sections": [{"heading": "Report", "body": "Claim [^p1]."}],
"citations": [
{"passage_id": "p1", "url": "https://example.org/x", "title": "X", "quote": "q", "health": "live", "authority_tier": "reference"}
],
}
_LEDGER_A = {
"run_id": "runaaaa1",
"generated_at": "2026-09-20T14:32:00Z",
"entries": [
{"url": "https://example.org/x", "visited_at": "2026-09-20T14:00:00Z", "passages": ["p-1"], "claims_supported": 1, "health": "live", "authority_tier": "reference"},
{"url": "https://example.org/y", "visited_at": "2026-09-20T14:00:00Z", "passages": ["p-2"], "claims_supported": 1, "health": "live", "authority_tier": "general"},
],
"checklist": {},
}
_LEDGER_B = {
"run_id": "runbbbb1",
"generated_at": "2026-09-22T09:14:00Z",
"entries": [
{"url": "https://example.org/x", "visited_at": "2026-09-22T09:00:00Z", "passages": ["p-1"], "claims_supported": 2, "health": "live", "authority_tier": "reference"},
{"url": "https://example.org/z", "visited_at": "2026-09-22T09:00:00Z", "passages": ["p-2"], "claims_supported": 1, "health": "stale", "authority_tier": "general"},
],
"checklist": {},
}
def _state(
run_id: str,
question: str,
slug: str,
state: str,
plan: dict | None,
created_at: str,
) -> dict:
return {
"run_id": run_id,
"question": question,
"context": "",
"methodology_slug": slug,
"state": state,
"owner_id": _U1,
"plan": plan,
"depth_cursor": 0,
"depth_target": 1,
"counters": {},
"probe": None,
"created_at": created_at,
"updated_at": created_at,
}
def _write_run(
base: Path,
owner: str,
run_id: str,
state: dict,
report: dict | None = None,
ledger: dict | None = None,
) -> None:
run_dir = base / "runs" / owner / run_id
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "state.json").write_text(json.dumps(state), encoding="utf-8")
if report is not None:
(run_dir / "report.json").write_text(json.dumps(report), encoding="utf-8")
if ledger is not None:
(run_dir / "ledger.json").write_text(json.dumps(ledger), encoding="utf-8")
def _tree(tmp_path: Path) -> Path:
"""The fixture tree: user 1 has DONE A (deep-report, battery), DONE
B (rapid-answer, battery safety), a CANCELLED run, and a FAILED
run; user 2 has one DONE run invisible to user 1."""
base = tmp_path
_write_run(
base,
_U1,
"runaaaa1",
_state(
"runaaaa1",
"What are the latest advances in solid-state batteries?",
"deep-report",
"DONE",
_PLAN_A,
"2026-09-20T14:32:00Z",
),
report=_REPORT_A,
ledger=_LEDGER_A,
)
_write_run(
base,
_U1,
"runbbbb1",
_state(
"runbbbb1",
"How do battery safety standards evolve?",
"rapid-answer",
"DONE",
_PLAN_B,
"2026-09-22T09:14:00Z",
),
ledger=_LEDGER_B,
)
_write_run(
base,
_U1,
"runcanc1",
_state(
"runcanc1",
"abandoned mid-flight",
"deep-report",
"CANCELLED",
_PLAN_CANCELLED,
"2026-09-21T10:00:00Z",
),
)
_write_run(
base,
_U1,
"runfail1",
_state(
"runfail1",
"it broke",
"rapid-answer",
"FAILED",
_PLAN_FAILED,
"2026-09-23T10:00:00Z",
),
)
# Defensive-scan fodder (D-115): dotfile + tmp leftovers + junk.
(base / "runs" / _U1 / ".hidden").mkdir(parents=True)
(base / "runs" / _U1 / "runbbbb1.tmp").mkdir(parents=True)
(base / "runs" / _U1 / "orphan001").mkdir(parents=True) # no state.json: skipped
# An in-flight run (raw, pre-sweep — the read-model sees the state
# as written; the sweep happens at create_app in the route layer).
_write_run(
base,
_U1,
"runinflg1",
_state(
"runinflg1",
"still running",
"deep-report",
"SEARCHING",
_PLAN_INFLIGHT,
"2026-09-25T08:00:00Z",
),
)
# The other user's run: invisible at every read-model level.
_write_run(
base,
_U2,
"runcccc1",
_state(
"runcccc1",
"user two's private research",
"deep-report",
"DONE",
_PLAN_B,
"2026-09-24T10:00:00Z",
),
ledger=_LEDGER_A,
)
return base
# ------------------------------------------------------------------ scanning
def test_list_runs_scans_owner_partition_only(tmp_path: Path) -> None:
base = _tree(tmp_path)
ids = [record.run_id for record in list_runs(base, _U1)]
# CANCELLED + FAILED + in-flight included; dotfile/tmp/orphan
# skipped; user 2's run absent.
assert set(ids) == {"runaaaa1", "runbbbb1", "runcanc1", "runfail1", "runinflg1"}
def test_list_runs_orders_most_recent_first(tmp_path: Path) -> None:
base = _tree(tmp_path)
ids = [record.run_id for record in list_runs(base, _U1)]
assert ids[0] == "runinflg1" # 2026-09-25
assert ids[-1] == "runaaaa1" # 2026-09-20
def test_list_runs_skips_dotfiles_and_tmp(tmp_path: Path) -> None:
base = _tree(tmp_path)
ids = {record.run_id for record in list_runs(base, _U1)}
assert ".hidden" not in ids
assert "runbbbb1.tmp" not in ids
def test_list_runs_empty_for_fresh_user(tmp_path: Path) -> None:
base = _tree(tmp_path)
assert list_runs(base, "cccc3333") == []
# ------------------------------------------------------------ categorization
def test_categorize_is_methodology_plus_first_topic_token() -> None:
record = list_runs # placeholder to keep import honest
del record
from researchx.api.read_models import RunDiskRecord
run = RunDiskRecord(
run_id="runaaaa1",
state={
"run_id": "runaaaa1",
"question": "q",
"methodology_slug": "deep-report",
"state": "DONE",
"plan": _PLAN_A,
"created_at": "2026-09-20T14:32:00Z",
},
)
assert categorize(run) == "deep-report-battery"
def test_topic_tokens_skip_stopwords_and_dedupe() -> None:
from researchx.api.read_models import RunDiskRecord
run = RunDiskRecord(
run_id="r",
state={
"run_id": "r",
"question": "q",
"methodology_slug": "rapid-answer",
"state": "DONE",
"plan": {"sub_questions": ["The what of battery battery gains"]},
"created_at": "2026-09-20T14:32:00Z",
},
)
assert topic_tokens(run) == ["battery", "gains"]
# ------------------------------------------------------------------ library
def test_library_folders_and_summaries(tmp_path: Path) -> None:
base = _tree(tmp_path)
payload = library(base, _U1)
# Folders from the two DONE runs' categories, sorted by id.
assert [folder["id"] for folder in payload["folders"]] == [
"deep-report-battery",
"rapid-answer-battery",
]
assert payload["folders"][0]["name"] == "Deep Report: Battery"
# Only DONE runs are filed (cancelled/failed produce nothing).
assert {report["runId"] for report in payload["reports"]} == {"runaaaa1", "runbbbb1"}
first = next(r for r in payload["reports"] if r["runId"] == "runaaaa1")
assert first["id"] == "rep-runaaaa1"
assert first["title"] == "Battery Advances"
assert first["methodologySlug"] == "deep-report"
assert first["category"] == "deep-report-battery"
assert first["generatedAt"] == "2026-09-20T14:32:00Z"
assert first["citationCount"] == 1
# Pre-artifact DONE run: falls back to state created_at + methodology title.
second = next(r for r in payload["reports"] if r["runId"] == "runbbbb1")
assert second["generatedAt"] == "2026-09-22T09:14:00Z"
assert second["title"] == "Rapid Answer — How do battery safety standards evolve?"
def test_library_sort_recent_and_title(tmp_path: Path) -> None:
base = _tree(tmp_path)
recent = [r["runId"] for r in library(base, _U1, sort="recent")["reports"]]
title = [r["runId"] for r in library(base, _U1, sort="title")["reports"]]
# recent: newest first (created_at desc).
assert recent == ["runbbbb1", "runaaaa1"]
# title: "Battery Advances" < "Rapid Answer — ..." A-Z.
assert title == ["runaaaa1", "runbbbb1"]
# unknown sort key falls back to recent.
assert library(base, _U1, sort="bogus")["reports"][0]["runId"] == "runbbbb1"
def test_library_fresh_user_is_empty(tmp_path: Path) -> None:
base = _tree(tmp_path)
payload = library(base, "cccc3333")
assert payload == {"folders": [], "reports": []}
def test_library_cross_user_isolation(tmp_path: Path) -> None:
base = _tree(tmp_path)
payload = library(base, _U1)
assert all(report["runId"] != "runcccc1" for report in payload["reports"])
# ------------------------------------------------------------- connections
def test_connections_shared_source_edge(tmp_path: Path) -> None:
base = _tree(tmp_path)
edges = connections(base, _U1)
shared = [e for e in edges if e["kind"] == "shared-sources"]
# runaaaa1 + runbbbb1 share https://example.org/x (>=1 URL overlap).
assert shared == [
{
"fromReportId": "rep-runaaaa1",
"toReportId": "rep-runbbbb1",
"kind": "shared-sources",
"label": "1 shared source",
}
]
def test_connections_related_topic_edge_when_no_shared_urls(tmp_path: Path) -> None:
base = _tree(tmp_path)
# Strip the shared URL: the two DONE runs then connect only via
# category (same methodology + first topic token) — no, they have
# DIFFERENT methodologies. Instead verify the pairing rule: with no
# shared URL and differing categories there is NO edge.
other = base / "runs" / _U1 / "runbbbb1" / "ledger.json"
payload = json.loads(other.read_text(encoding="utf-8"))
payload["entries"] = [e for e in payload["entries"] if e["url"] != "https://example.org/x"]
other.write_text(json.dumps(payload), encoding="utf-8")
edges = connections(base, _U1)
# deep-report-battery vs rapid-answer-battery: different methodology
# folders -> not related-topic, and no shared source -> no edge.
assert edges == []
def test_connections_related_topic_edge_on_same_category(tmp_path: Path) -> None:
base = tmp_path / "iso"
_write_run(
base,
_U1,
"runx0011",
_state("runx0011", "battery density", "deep-report", "DONE", _PLAN_A, "2026-09-20T10:00:00Z"),
ledger={"entries": [{"url": "https://example.org/x"}], "checklist": {}},
)
_write_run(
base,
_U1,
"runx0022",
_state("runx0022", "battery pilots", "deep-report", "DONE", _PLAN_A, "2026-09-21T10:00:00Z"),
ledger={"entries": [{"url": "https://example.org/q"}], "checklist": {}},
)
edges = connections(base, _U1)
assert edges == [
{
"fromReportId": "rep-runx0011",
"toReportId": "rep-runx0022",
"kind": "related-topic",
"label": "Adjacent topic",
}
]
def test_connections_cross_user_isolation(tmp_path: Path) -> None:
base = _tree(tmp_path)
edges = connections(base, _U1)
for edge in edges:
assert "runcccc1" not in (edge["fromReportId"], edge["toReportId"])
# -------------------------------------------------------------------- graph
def test_graph_nodes_and_edges(tmp_path: Path) -> None:
base = _tree(tmp_path)
payload = graph(base, _U1)
node_ids = {node["id"] for node in payload["nodes"]}
assert node_ids == {"runaaaa1", "runbbbb1"} # DONE runs only
node = next(n for n in payload["nodes"] if n["id"] == "runaaaa1")
assert node["topic"] == "Battery Advances"
assert set(node["linkedSources"]) == {"https://example.org/x", "https://example.org/y"}
assert node["findings"] == ["Battery Advances"]
assert payload["edges"] == [
{"fromId": "runaaaa1", "toId": "runbbbb1", "sharedSourceCount": 1}
]
def test_graph_fresh_user_is_empty(tmp_path: Path) -> None:
assert graph(_tree(tmp_path), "cccc3333") == {"nodes": [], "edges": []}
# -------------------------------------------------------------------- tasks
def test_tasks_answered_iff_done(tmp_path: Path) -> None:
"""D-114: a sub-question is done IFF its run reached DONE; the
post-sweep CANCELLED run's questions stay open."""
base = _tree(tmp_path)
items = tasks(base, _U1)
by_id = {item["id"]: item for item in items}
# DONE runs -> done items with <run_id>#<idx> ids.
assert by_id["runaaaa1#0"] == {
"id": "runaaaa1#0",
"question": "battery energy density gains",
"sourceRunId": "runaaaa1",
"status": "done",
}
assert by_id["runbbbb1#0"]["status"] == "done"
# CANCELLED + FAILED + in-flight runs -> open items.
assert by_id["runcanc1#0"]["status"] == "open"
assert by_id["runcanc1#0"]["question"] == "abandoned open question"
assert by_id["runfail1#0"]["status"] == "open"
assert by_id["runinflg1#0"]["status"] == "open"
# user 2's runs absent.
assert all("runcccc1" not in item["id"] for item in items)
def test_tasks_cross_user_isolation(tmp_path: Path) -> None:
items = tasks(_tree(tmp_path), "cccc3333")
assert items == []
# -------------------------------------------------------------- suggestions
def test_suggestions_are_open_items_joined_with_titles(tmp_path: Path) -> None:
base = _tree(tmp_path)
payload = suggestions(base, _U1)
# Open items from the CANCELLED + FAILED + in-flight runs, most
# recent run first, attributed to their source run titles; nothing
# synthetic, nothing from DONE runs.
assert payload == [
{
"id": "sug-runinflg1#0",
"question": "still running question",
"sourceRunId": "runinflg1",
"sourceReportTitle": "Deep Report — still running",
},
{
"id": "sug-runfail1#0",
"question": "failed run question",
"sourceRunId": "runfail1",
"sourceReportTitle": "Rapid Answer — it broke",
},
{
"id": "sug-runcanc1#0",
"question": "abandoned open question",
"sourceRunId": "runcanc1",
"sourceReportTitle": "Deep Report — abandoned mid-flight",
},
]
def test_suggestions_fresh_user_empty_never_synthetic(tmp_path: Path) -> None:
base = _tree(tmp_path)
assert suggestions(base, "cccc3333") == []
def test_suggestions_exclude_done_runs(tmp_path: Path) -> None:
base = _tree(tmp_path)
questions = [s["question"] for s in suggestions(base, _U1)]
assert "battery energy density gains" not in questions # DONE run
# ------------------------------------------------- defensive scanning extras
def test_list_runs_skips_corrupt_state_json(tmp_path: Path) -> None:
base = tmp_path
run_dir = base / "runs" / _U1 / "corrupt01"
run_dir.mkdir(parents=True)
(run_dir / "state.json").write_text("{not json", encoding="utf-8")
assert list_runs(base, _U1) == []
+2 -2
View File
@@ -40,7 +40,7 @@ async def test_state_file_round_trip_enables_restart(tmp_path: Path) -> None:
registry=registry,
run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
state = RunState(
run_id=run_id,
question=config.question,
@@ -68,7 +68,7 @@ async def test_actor_resumes_from_restored_state(tmp_path: Path) -> None:
registry=registry,
run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
mid_state = RunState(
run_id=run_id,
question=config.question,
@@ -0,0 +1,651 @@
"""Settings + model-key routes + factory + config-status suite (STEP 2).
Covers T-2.4 (settings/key routes), T-2.5 (per-run model factory
chain: user → server → keyless), and T-2.6 (config status rework —
P-1 closure: factory-resolved probe for the CURRENT user, zero runs
required, no cross-user leak).
Run under the ``auth_enabled=True`` harness (two users prove per-user
isolation) plus direct factory tests against fake key stores.
"""
import base64
import json
import os
from pathlib import Path
import httpx
import pytest
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.model_factory import ModelAdapterFactory
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.types import ChatResult
from researchx.api.app import create_app
from researchx.auth.guard import CSRF_HEADER
from researchx.auth.key_store import KEK_NAME, KeyStore
from researchx.auth.models import UserPublic
from researchx.auth.settings import UserSettings
_PLAN = json.dumps(
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
)
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
_ADMIN_EMAIL = "admin@example.org"
_ADMIN_PASSWORD = "correct-horse-battery"
_SECOND_EMAIL = "second@example.org"
_SECOND_PASSWORD = "another-long-password"
_HEADERS = {CSRF_HEADER: "1"}
_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode()
_FAKE_KEY = "sk-user-tier-key-9999"
def _env_file(tmp_path: Path, values: dict[str, str]) -> Path:
env_file = tmp_path / ".env.secrets"
env_file.write_text("".join(f"{k}={v}\n" for k, v in values.items()), encoding="utf-8")
return env_file
def _user(user_id: str = "abcd2345") -> UserPublic:
from datetime import UTC, datetime
return UserPublic(
user_id=user_id,
email="user@example.org",
display_name="User",
role="user",
active=True,
created_at=datetime.now(UTC),
)
# ------------------------------------------------------------- harness
def _app(
tmp_path: Path,
*,
secrets_values: dict[str, str] | None = None,
with_factory: bool = True,
):
"""create_app under auth_enabled=True with live per-user stores.
The model adapter stays the zero-key fake (the fake's chat is what
runs execute against); the FACTORY is wired so the tier chain and
config-status rework are exercised end to end. The factory's bases
point at an unroutable host so config-status probes fail fast and
hermetically (no outbound HTTP from the unit tier — the probe
contract has its own monkeypatched tests below).
"""
secrets = EnvFileSecretsProvider(
paths=[_env_file(tmp_path, secrets_values or {})]
)
return create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER, model="fake-writer"),
]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
secrets_provider=secrets,
model_factory=ModelAdapterFactory(
secrets,
KeyStore(secrets, tmp_path),
cloud_base_url="http://127.0.0.1:1/v1",
local_base_url="http://127.0.0.1:1/v1",
)
if with_factory
else None,
)
async def _client(tmp_path: Path, **kwargs) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=_app(tmp_path, **kwargs)),
base_url="http://testserver",
)
async def _register_and_login(client: httpx.AsyncClient) -> dict[str, str]:
await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
headers=_HEADERS,
)
login = await client.post(
"/api/auth/login", json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD}, headers=_HEADERS
)
return login.json()
# --------------------------------------------------- settings CRUD routes
async def test_settings_get_returns_defaults(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
me = await _register_and_login(client)
response = await client.get("/api/config/settings")
assert response.status_code == 200
body = response.json()
assert body["planner_model"]
assert body["writer_model"]
assert body["methodology_defaults"] == {}
assert body["appearance"] == "system"
# never leaks the user's id/email into the settings payload
assert "user_id" not in body
assert me["email"] not in json.dumps(body)
async def test_settings_put_round_trip(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_and_login(client)
put = await client.put(
"/api/config/settings",
json={"planner_model": "qwen-max", "appearance": "dark"},
headers=_HEADERS,
)
assert put.status_code == 200
assert put.json()["planner_model"] == "qwen-max"
assert put.json()["appearance"] == "dark"
# writer_model untouched -> default preserved
assert put.json()["writer_model"]
got = await client.get("/api/config/settings")
assert got.json()["planner_model"] == "qwen-max"
assert got.json()["appearance"] == "dark"
async def test_settings_put_partial_preserves(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_and_login(client)
await client.put(
"/api/config/settings", json={"planner_model": "p-one"}, headers=_HEADERS
)
put = await client.put(
"/api/config/settings", json={"writer_model": "w-two"}, headers=_HEADERS
)
assert put.json()["planner_model"] == "p-one"
assert put.json()["writer_model"] == "w-two"
async def test_settings_put_validates_body(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_and_login(client)
response = await client.put(
"/api/config/settings", json={"planner_model": ""}, headers=_HEADERS
)
assert response.status_code == 422
async def test_settings_put_requires_csrf(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_and_login(client)
response = await client.put("/api/config/settings", json={"appearance": "dark"})
assert response.status_code == 403
async def test_settings_put_explicit_null_treated_as_absent(tmp_path: Path) -> None:
"""P2-verify P0 regression: an explicit null field must be treated
as absent (partial-merge semantics) — never 200-then-poison the
on-disk payload into a corrupt file that 500s every later read."""
async with await _client(tmp_path) as client:
await _register_and_login(client)
put = await client.put(
"/api/config/settings", json={"planner_model": None}, headers=_HEADERS
)
assert put.status_code == 200
body = put.json()
assert body["planner_model"] # default preserved, not null
# Every subsequent read + write + run creation stays healthy.
got = await client.get("/api/config/settings")
assert got.status_code == 200
assert got.json()["planner_model"]
again = await client.put(
"/api/config/settings", json={"planner_model": "qwen-max"}, headers=_HEADERS
)
assert again.status_code == 200
assert again.json()["planner_model"] == "qwen-max"
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
)
assert created.status_code == 201
async def test_settings_isolated_per_user(tmp_path: Path) -> None:
app = _app(tmp_path)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _register_and_login(admin)
await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
await admin.put(
"/api/config/settings", json={"appearance": "dark"}, headers=_HEADERS
)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
await second.post(
"/api/auth/login",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
got = await second.get("/api/config/settings")
assert got.json()["appearance"] == "system" # no bleed
# --------------------------------------------------- model-key routes
async def test_model_key_put_masked_round_trip(tmp_path: Path) -> None:
"""PUT the raw key -> 200 with masked status only; the raw key
NEVER appears in any response (REQ-73)."""
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
me = await _register_and_login(client)
put = await client.put(
"/api/config/settings/model-key",
json={"key": _FAKE_KEY},
headers=_HEADERS,
)
assert put.status_code == 200
body = put.json()
assert body["configured"] is True
assert body["masked"] is not None
assert body["masked"].endswith(_FAKE_KEY[-4:])
assert _FAKE_KEY not in put.text
status = await client.get("/api/config/settings/model-key")
assert status.json()["configured"] is True
assert _FAKE_KEY not in status.text
assert me["user_id"] not in status.text
async def test_model_key_put_disabled_without_kek_honest_409(tmp_path: Path) -> None:
"""No KEK -> 409 with the honest {key_store: disabled} payload —
never a silent acceptance."""
async with await _client(tmp_path) as client: # no KEK in secrets
await _register_and_login(client)
put = await client.put(
"/api/config/settings/model-key",
json={"key": "some-key-value"},
headers=_HEADERS,
)
assert put.status_code == 409
detail = put.json()["detail"]
assert detail["key_store"] == "disabled"
assert "RESEARCHX_KEK" in detail["reason"]
# Nothing was written to disk.
user_dir = tmp_path / "keys"
assert not user_dir.exists() or not any(user_dir.iterdir())
async def test_model_key_get_status_disabled_says_so(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_and_login(client)
status = await client.get("/api/config/settings/model-key")
assert status.status_code == 200
body = status.json()
assert body["disabled"] is True
assert "RESEARCHX_KEK" in body["reason"]
assert body["configured"] is False
async def test_model_key_put_requires_csrf(tmp_path: Path) -> None:
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
await _register_and_login(client)
response = await client.put(
"/api/config/settings/model-key", json={"key": "no-header-key"}
)
assert response.status_code == 403
async def test_model_key_never_in_config_or_settings_responses(tmp_path: Path) -> None:
"""The raw key must not surface in ANY authenticated response."""
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
await _register_and_login(client)
await client.put(
"/api/config/settings/model-key", json={"key": _FAKE_KEY}, headers=_HEADERS
)
for path in [
"/api/config",
"/api/config/settings",
"/api/config/settings/model-key",
"/api/auth/me",
"/api/methodologies",
]:
response = await client.get(path)
assert _FAKE_KEY not in response.text, f"raw key leaked via {path}"
# --------------------------------------------------------- factory chain
class _FakeKeyStore:
"""Test double for KeyStore: enabled with a known key or disabled."""
def __init__(self, key: str | None = None, enabled: bool = True) -> None:
self._key = key
self._enabled_flag = enabled
@property
def enabled(self) -> bool:
return self._enabled_flag
async def get_key(self, user_id: str) -> str | None:
del user_id
return self._key
async def masked_status(self, user_id: str) -> dict[str, object]:
del user_id
return {
"configured": self._key is not None,
"masked": f"…{self._key[-4:]}" if self._key else None,
"disabled": not self._enabled_flag,
}
def _factory(tmp_path: Path, secrets_values: dict[str, str], key_store: object) -> ModelAdapterFactory:
secrets = EnvFileSecretsProvider(paths=[_env_file(tmp_path, secrets_values)])
return ModelAdapterFactory(
secrets,
key_store, # type: ignore[arg-type]
cloud_base_url="http://127.0.0.1:1/v1", # unroutable: hermetic unit tier
local_base_url="http://127.0.0.1:1/v1",
)
async def test_factory_user_tier_when_key_present(tmp_path: Path) -> None:
factory = _factory(
tmp_path,
{"OLLAMA_API_KEY": "server-key-value"},
_FakeKeyStore(key=_FAKE_KEY),
)
adapter, tier = await factory.resolve(_user(), UserSettings())
assert tier == "user"
assert adapter.roles().planner == UserSettings().planner_model
async def test_factory_server_tier_when_no_user_key(tmp_path: Path) -> None:
factory = _factory(
tmp_path,
{"OLLAMA_API_KEY": "server-key-value"},
_FakeKeyStore(key=None),
)
_adapter, tier = await factory.resolve(_user(), UserSettings())
assert tier == "server"
async def test_factory_keyless_when_neither(tmp_path: Path) -> None:
factory = _factory(tmp_path, {}, _FakeKeyStore(key=None))
_adapter, tier = await factory.resolve(_user(), UserSettings())
assert tier == "keyless"
async def test_factory_keyless_when_store_disabled(tmp_path: Path) -> None:
factory = _factory(
tmp_path,
{"OLLAMA_API_KEY": "server-key-value"},
_FakeKeyStore(key=None, enabled=False),
)
_adapter, tier = await factory.resolve(_user(), UserSettings())
assert tier == "server" # chain proceeds to server when key store disabled
async def test_factory_respects_user_settings_models(tmp_path: Path) -> None:
factory = _factory(tmp_path, {}, _FakeKeyStore(key=None))
settings = UserSettings(planner_model="my-planner", writer_model="my-writer")
adapter, tier = await factory.resolve(_user(), settings)
assert tier == "keyless"
assert adapter.roles().planner == "my-planner"
assert adapter.roles().writer == "my-writer"
async def test_factory_never_caches_adapters(tmp_path: Path) -> None:
factory = _factory(tmp_path, {}, _FakeKeyStore(key=None))
first, _ = await factory.resolve(_user(), UserSettings())
second, _ = await factory.resolve(_user(), UserSettings())
assert first is not second # one instance per run (P-2 closure)
async def test_factory_user_key_precedence_over_server_secrets(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The resolved user key must be the Authorization the adapter sends
— not the server env-file key."""
seen_headers: dict[str, str] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen_headers.update(dict(request.headers))
return httpx.Response(
200, json={"choices": [{"message": {"content": '{"ok": true}'}}]}
)
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(
httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs)
)
factory = _factory(
tmp_path,
{"OLLAMA_API_KEY": "server-key-value"},
_FakeKeyStore(key=_FAKE_KEY),
)
adapter, tier = await factory.resolve(_user(), UserSettings())
assert tier == "user"
await adapter.chat(
[__import__("researchx.adapters.types", fromlist=["ModelMessage"]).ModelMessage(
role="user", content="hi"
)],
model="w",
)
assert seen_headers["authorization"] == f"Bearer {_FAKE_KEY}"
# ------------------------------------------------- config status rework
async def test_config_status_tier_per_user_no_cross_leak(tmp_path: Path) -> None:
"""Config status shows the CURRENT user's tier: a keyed user shows
'user', a keyless second user shows 'server' — zero runs needed
(P-1 closure), zero cross-user bleed."""
app = _app(tmp_path, secrets_values={KEK_NAME: _KEK, "OLLAMA_API_KEY": "server-key-value"})
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _register_and_login(admin)
await admin.put(
"/api/config/settings/model-key",
json={"key": _FAKE_KEY},
headers=_HEADERS,
)
config = await admin.get("/api/config")
assert config.status_code == 200
body = config.json()
assert body["resolved_tier"] == "user"
assert body["key_store"] == "enabled"
assert body["key_status"]["configured"] is True
assert body["key_status"]["masked"].endswith(_FAKE_KEY[-4:])
assert body["cost_counters"] == {"runs_created": 0, "runs_completed": 0}
await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
await second.post(
"/api/auth/login",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
config = await second.get("/api/config")
body = config.json()
assert body["resolved_tier"] == "server" # no user key of their own
assert body["key_store"] == "enabled" # the STORE is enabled; the user has no key
assert body["key_status"]["configured"] is False
assert _FAKE_KEY not in config.text
assert _FAKE_KEY[-4:] not in config.text
# Counters owner-keyed: the second user sees zero, not the admin's runs.
assert body["cost_counters"] == {"runs_created": 0, "runs_completed": 0}
async def test_config_status_keyless_when_no_keys(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_and_login(client)
config = await client.get("/api/config")
body = config.json()
assert body["resolved_tier"] == "keyless"
assert body["key_store"] == "disabled"
assert body["key_status"]["disabled"] is True
assert body["model_adapter"] == "OllamaModelAdapter" # factory-constructed
async def test_config_status_works_with_zero_runs(tmp_path: Path) -> None:
"""P-1: correct config status with zero runs in the registry."""
async with await _client(tmp_path) as client:
await _register_and_login(client)
response = await client.get("/api/config")
assert response.status_code == 200
assert response.json()["resolved_tier"] in ("user", "server", "keyless")
assert response.json()["cost_counters"]["runs_created"] == 0
async def test_config_status_probe_cached_and_error_safe(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Probe semantics pinned hermetically: a successful probe is
cached in-process (second call does not re-probe), and an
AdapterError probe yields probe: None (never a 500)."""
import researchx.api.app as app_module
calls: list[int] = []
probe_payload = {
"endpoint": "http://127.0.0.1:1/v1",
"models": ["m"],
"context_window": 8192,
"embedding_batch": 8,
"structured_outputs_supported": True,
}
from researchx.adapters.probe import CapabilityProbe
fake_probe = CapabilityProbe(
endpoint="http://127.0.0.1:1/v1",
models=["m"],
context_window=8192,
embedding_batch=8,
structured_outputs_supported=True,
)
async def _fake_probe(adapter):
calls.append(1)
return fake_probe
async def _failing_probe(adapter):
from researchx.adapters.types import AdapterError
raise AdapterError("unreachable")
monkeypatch.setattr(app_module, "probe_model_adapter", _fake_probe)
async with await _client(tmp_path) as client:
await _register_and_login(client)
first = await client.get("/api/config")
assert first.status_code == 200
assert first.json()["probe"]["endpoint"] == probe_payload["endpoint"]
assert len(calls) == 1
second = await client.get("/api/config")
assert len(calls) == 1 # served from the (user, tier, fingerprint) cache
assert second.json()["probe"]["endpoint"] == probe_payload["endpoint"]
# Fresh app: a failing probe yields None, cached — never a 500, and
# the route stays healthy on repeat calls.
monkeypatch.setattr(app_module, "probe_model_adapter", _failing_probe)
async with await _client(tmp_path) as client:
await _register_and_login(client)
first = await client.get("/api/config")
assert first.status_code == 200
assert first.json()["probe"] is None
second = await client.get("/api/config")
assert second.status_code == 200
assert second.json()["probe"] is None
async def test_config_status_uses_factory_adapter_not_fixed_one(tmp_path: Path) -> None:
"""The rework: model_adapter names the FACTORY-constructed adapter
(OllamaModelAdapter), not the fixed injected fake."""
async with await _client(tmp_path) as client:
await _register_and_login(client)
config = await client.get("/api/config")
assert config.json()["model_adapter"] == "OllamaModelAdapter"
async def test_config_status_without_factory_names_fixed_adapter(tmp_path: Path) -> None:
"""Back-compat: with no factory wired (pure test harness), the
config status still answers with the injected adapter's name."""
async with await _client(tmp_path, with_factory=False) as client:
await _register_and_login(client)
config = await client.get("/api/config")
body = config.json()
assert body["model_adapter"] == "FakeModelAdapter"
assert body["resolved_tier"] == "keyless"
# ----------------------------------------------- create_run tier stamping
async def test_create_run_stamps_credential_tier(tmp_path: Path) -> None:
"""create_run resolves the factory and stamps state.credential_tier
(REQ-74); the engine contracts are otherwise unchanged."""
async with await _client(tmp_path) as client:
me = await _register_and_login(client)
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
)
assert created.status_code == 201
run_id = created.json()["id"]
state_path = tmp_path / "runs" / me["user_id"] / run_id / "state.json"
import asyncio
for _ in range(200):
if state_path.exists():
break
await asyncio.sleep(0.01)
state = json.loads(state_path.read_text(encoding="utf-8"))
assert state["credential_tier"] == "keyless"
assert state["owner_id"] == me["user_id"]
async def test_create_run_user_tier_uses_user_key(tmp_path: Path) -> None:
"""A keyed user's run executes with THEIR key through the factory
chain (fake model still serves the run — zero-key CI)."""
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
me = await _register_and_login(client)
await client.put(
"/api/config/settings/model-key", json={"key": _FAKE_KEY}, headers=_HEADERS
)
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
)
assert created.status_code == 201
run_id = created.json()["id"]
import asyncio
state_path = tmp_path / "runs" / me["user_id"] / run_id / "state.json"
for _ in range(200):
if state_path.exists():
break
await asyncio.sleep(0.01)
state = json.loads(state_path.read_text(encoding="utf-8"))
assert state["credential_tier"] == "user"
+247
View File
@@ -0,0 +1,247 @@
"""Restart sweep + registry pruning tests (T-1.8, D-115/G-101, P-6)."""
import asyncio
import json
from pathlib import Path
import httpx
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.types import ChatResult
from researchx.api.app import RunRegistry, create_app
from researchx.api.sweep import sweep_in_flight_runs
from researchx.auth.guard import CSRF_HEADER
from researchx.engine.run_state import RunState
from researchx.engine.states import RunStateName, can_transition
_PLAN = json.dumps(
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
)
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
_OWNER = "abcd2345" # owner slug used for fixture trees
def _state_file(run_dir: Path) -> Path:
run_dir.mkdir(parents=True, exist_ok=True)
return run_dir / "state.json"
def _write_state(run_dir: Path, state: RunStateName) -> Path:
state_path = _state_file(run_dir)
record = RunState(
run_id=run_dir.name,
question="q",
methodology_slug="rapid-answer",
state=state,
owner_id=_OWNER,
)
state_path.write_text(record.model_dump_json(), encoding="utf-8")
return state_path
# ------------------------------------------------------------------- G-101
def test_planning_to_cancelled_is_now_legal() -> None:
"""G-101: the sweep's PLANNING -> CANCELLED rewrite is a legal transition."""
assert can_transition(RunStateName.PLANNING, RunStateName.CANCELLED)
def test_sweep_rewrites_planning_fixture_to_cancelled(tmp_path: Path) -> None:
base = tmp_path
_write_state(base / "runs" / _OWNER / "abcd2345", RunStateName.PLANNING)
swept = sweep_in_flight_runs(base)
assert swept == [(_OWNER, "abcd2345")]
state = RunState.model_validate_json(
(base / "runs" / _OWNER / "abcd2345" / "state.json").read_text(encoding="utf-8")
)
assert state.state == RunStateName.CANCELLED
def test_sweep_rewrites_every_non_terminal_state(tmp_path: Path) -> None:
base = tmp_path
non_terminal = [
RunStateName.PLANNING,
RunStateName.AWAITING_APPROVAL,
RunStateName.SEARCHING,
RunStateName.EXTRACTING,
RunStateName.REFLECTING,
RunStateName.SYNTHESIZING,
RunStateName.VERIFYING,
]
for state in non_terminal:
_write_state(base / "runs" / _OWNER / f"run{state.value[:4].lower()}", state)
swept = sweep_in_flight_runs(base)
assert len(swept) == len(non_terminal)
for _, run_id in swept:
record = RunState.model_validate_json(
(base / "runs" / _OWNER / run_id / "state.json").read_text(encoding="utf-8")
)
assert record.state == RunStateName.CANCELLED
def test_sweep_leaves_terminal_states_untouched(tmp_path: Path) -> None:
base = tmp_path
_write_state(base / "runs" / _OWNER / "done0001", RunStateName.DONE)
_write_state(base / "runs" / _OWNER / "fail0001", RunStateName.FAILED)
_write_state(base / "runs" / _OWNER / "canc0001", RunStateName.CANCELLED)
assert sweep_in_flight_runs(base) == []
for run_id in ["done0001", "fail0001", "canc0001"]:
record = RunState.model_validate_json(
(base / "runs" / _OWNER / run_id / "state.json").read_text(encoding="utf-8")
)
assert record.state in {RunStateName.DONE, RunStateName.FAILED, RunStateName.CANCELLED}
def test_sweep_skips_corrupt_state_and_dotfiles(tmp_path: Path) -> None:
base = tmp_path
good = _write_state(base / "runs" / _OWNER / "abcd2345", RunStateName.SEARCHING)
corrupt = _state_file(base / "runs" / _OWNER / "zzzz9999")
corrupt.write_text("{not json", encoding="utf-8")
(base / "runs" / _OWNER / ".hidden").mkdir() # dotfile dir skipped
(base / "runs" / ".hidden-owner").mkdir() # dotfile owner skipped
swept = sweep_in_flight_runs(base)
assert swept == [(_OWNER, "abcd2345")]
assert corrupt.read_text(encoding="utf-8") == "{not json" # untouched
del good
def test_sweep_on_empty_base_dir_is_noop(tmp_path: Path) -> None:
assert sweep_in_flight_runs(tmp_path) == []
assert sweep_in_flight_runs(tmp_path / "missing") == []
def test_create_app_sweeps_on_construction(tmp_path: Path) -> None:
"""The PLANNING fixture -> create_app -> state is CANCELLED on disk."""
_write_state(tmp_path / "runs" / _OWNER / "abcd2345", RunStateName.PLANNING)
create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[ChatResult(content=_PLAN, model="fake-planner")]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
state = RunState.model_validate_json(
(tmp_path / "runs" / _OWNER / "abcd2345" / "state.json").read_text(encoding="utf-8")
)
assert state.state == RunStateName.CANCELLED
# --------------------------------------------------------------------- P-6
async def test_registry_prune_drops_terminal_run_with_zero_subscribers() -> None:
registry = RunRegistry()
assert registry.prune("abcd2345", "efgh6789") is False # unknown entry: no-op
# Build a fake terminal entry.
from researchx.engine.events import EventBus
bus = EventBus()
class _FakeActor:
pass
class _FakeState:
state = RunStateName.DONE
fake_actor = _FakeActor()
fake_actor.state = _FakeState() # type: ignore[assignment]
async def _noop() -> RunState:
raise NotImplementedError
task: object = asyncio.Future() # placeholder, never awaited
registry.add("abcd2345", "efgh6789", fake_actor, task, bus) # type: ignore[arg-type]
assert registry.has("abcd2345", "efgh6789")
# A live subscriber blocks pruning.
registry.subscribe_events("abcd2345", "efgh6789")
assert registry.prune("abcd2345", "efgh6789") is False
assert registry.has("abcd2345", "efgh6789")
# Last subscriber leaves -> terminal run pruned.
registry.unsubscribe_events("abcd2345", "efgh6789")
assert registry.prune("abcd2345", "efgh6789") is True
assert not registry.has("abcd2345", "efgh6789")
assert registry.prune("abcd2345", "efgh6789") is False # already gone
def test_registry_refcounts_multiple_subscribers() -> None:
registry = RunRegistry()
registry.subscribe_events("abcd2345", "efgh6789")
registry.subscribe_events("abcd2345", "efgh6789")
assert registry.subscriber_count("abcd2345", "efgh6789") == 2
registry.unsubscribe_events("abcd2345", "efgh6789")
assert registry.subscriber_count("abcd2345", "efgh6789") == 1
registry.unsubscribe_events("abcd2345", "efgh6789")
assert registry.subscriber_count("abcd2345", "efgh6789") == 0
registry.unsubscribe_events("abcd2345", "efgh6789") # underflow-safe
assert registry.subscriber_count("abcd2345", "efgh6789") == 0
async def test_events_for_pruned_run_404(tmp_path: Path) -> None:
"""Terminal run + all subscribers gone -> registry drops it; the
events endpoint 404s afterwards (documented acceptable — the
trace persists on disk, D-115/P-6)."""
app = create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER, model="fake-writer"),
]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
)
run_id = created.json()["id"]
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "awaiting_approval":
break
await asyncio.sleep(0.01)
await client.post(f"/api/runs/{run_id}/approve", headers={CSRF_HEADER: "1"})
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "done":
break
await asyncio.sleep(0.01)
# Consume the full SSE replay; on stream end the run prunes.
collected = b""
async with client.stream("GET", f"/api/runs/{run_id}/events") as response:
assert response.status_code == 200
async for chunk in response.aiter_bytes():
collected += chunk
if b"run_done" in collected:
break
await asyncio.sleep(0.1) # let the finally-block prune
# The report still exists on disk...
report = await client.get(f"/api/runs/{run_id}/report")
assert report.status_code in (200, 404) # read-model parity lands in P3
state_path = list((tmp_path / "runs").glob(f"*/{run_id}/state.json"))
assert state_path, "trace/state persists on disk after prune"
# ...and a second events request for the pruned run 404s (the
# documented post-prune behavior).
assert (await client.get(f"/api/runs/{run_id}/events")).status_code == 404
+263
View File
@@ -0,0 +1,263 @@
"""Store-level tests for the auth package (T-1.1/T-1.2/T-1.3, REQ-69)."""
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from researchx.auth.sessions import SESSION_TTL_SECONDS, Session, SessionStore
from researchx.auth.users import MIN_PASSWORD_LENGTH, UserError, UserStore
_EMAIL = "owner@example.org"
_PASSWORD = "correct-horse-battery"
def _store(tmp_path: Path) -> UserStore:
return UserStore(tmp_path / "users.json")
def _sessions(tmp_path: Path) -> SessionStore:
return SessionStore(tmp_path / "sessions.json")
# ------------------------------------------------------------------ users
async def test_user_store_create_get_list(tmp_path: Path) -> None:
store = _store(tmp_path)
assert store.empty() is True
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner", role="admin"
)
assert store.empty() is False
assert user.role == "admin"
assert user.active is True
assert len(user.user_id) == 8
assert user.user_id.isascii() and user.user_id.islower()
fetched = store.get(user.user_id)
assert fetched is not None and fetched.email == _EMAIL
by_email = store.get_by_email("Owner@Example.ORG")
assert by_email is not None and by_email.user_id == user.user_id
listed = store.list()
assert [u.user_id for u in listed] == [user.user_id]
assert store.get("zzzz9999") is None
async def test_password_policy_min_12_enforced(tmp_path: Path) -> None:
store = _store(tmp_path)
with pytest.raises(UserError, match="at least 12"):
await store.create_user(
email=_EMAIL, password="short", display_name="Owner"
)
assert len("short") < MIN_PASSWORD_LENGTH
assert store.empty() is True # nothing inserted
with pytest.raises(UserError):
await store.create_user(
email=_EMAIL, password="a" * (MIN_PASSWORD_LENGTH - 1), display_name="Owner"
)
assert store.empty() is True
ok = await store.create_user(
email=_EMAIL, password="a" * MIN_PASSWORD_LENGTH, display_name="Owner"
)
assert ok.user_id
async def test_duplicate_email_rejected(tmp_path: Path) -> None:
store = _store(tmp_path)
await store.create_user(email=_EMAIL, password=_PASSWORD, display_name="A")
with pytest.raises(UserError, match="already registered"):
await store.create_user(
email="OWNER@example.org", password=_PASSWORD, display_name="B"
)
assert len(store.list()) == 1
async def test_argon2_hash_verify_round_trip(tmp_path: Path) -> None:
store = _store(tmp_path)
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner"
)
assert user.password_hash != _PASSWORD
assert user.password_hash.startswith("$argon2")
assert await store.verify_password(user, _PASSWORD) is True
wrong = user.model_copy(update={"password_hash": user.password_hash})
assert await store.verify_password(wrong, "wrong-password-entirely") is False
other = await _store(tmp_path).create_user(
email="b@example.org", password="another-password-12", display_name="B"
)
assert other.password_hash != user.password_hash # unique salts
async def test_user_store_persists_across_reload(tmp_path: Path) -> None:
path = tmp_path / "users.json"
store = UserStore(path)
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner", role="admin"
)
assert path.exists()
reloaded = UserStore(path)
fetched = reloaded.get(user.user_id)
assert fetched is not None
assert fetched.email == _EMAIL
assert await reloaded.verify_password(fetched, _PASSWORD) # hash survives the round-trip
assert reloaded.get_by_email("OWNER@EXAMPLE.ORG") is not None
async def test_user_store_deactivate(tmp_path: Path) -> None:
store = _store(tmp_path)
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner"
)
updated = store.deactivate(user.user_id)
assert updated is not None and updated.active is False
assert store.get(user.user_id).active is False
assert store.deactivate("zzzz9999") is None
# ---------------------------------------------------------------- sessions
async def test_session_create_get_round_trip(tmp_path: Path) -> None:
store = _store(tmp_path)
user = await store.create_user(email=_EMAIL, password=_PASSWORD, display_name="Owner")
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, user.user_id)
assert session.token_hash != token # hash-only storage
assert len(session.token_hash) == 64 # sha256 hex
resolved = sessions.get(token)
assert resolved is not None
assert resolved.user_id == user.user_id
assert sessions.get("not-a-real-token") is None
async def test_session_expiry(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, "abcd2345")
assert session.expires_at > datetime.now(UTC)
# Expired sessions resolve to None and are evicted.
expired = Session(
token_hash=session.token_hash,
user_id="abcd2345",
created_at=datetime.now(UTC) - timedelta(days=15),
last_seen=datetime.now(UTC) - timedelta(days=15),
expires_at=datetime.now(UTC) - timedelta(seconds=1),
)
sessions._sessions[session.token_hash] = expired
assert sessions.get(token) is None
assert session.token_hash not in sessions._sessions
async def test_session_sliding_touch(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, "abcd2345")
original_expiry = session.expires_at
old_seen = datetime.now(UTC) - timedelta(days=10)
stale = session.model_copy(
update={"last_seen": old_seen, "expires_at": old_seen + timedelta(seconds=1)}
)
sessions._sessions[stale.token_hash] = stale
sessions.touch(stale)
renewed = sessions.get(token)
assert renewed is not None
assert renewed.expires_at > original_expiry - timedelta(days=10)
assert renewed.last_seen > old_seen
assert renewed.expires_at - renewed.last_seen == timedelta(seconds=SESSION_TTL_SECONDS)
def test_session_touch_persist_throttle(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, "abcd2345")
path = sessions._path
first_write = path.stat().st_mtime_ns
sessions.touch(session) # within 60s of the create persist → throttled
assert path.stat().st_mtime_ns == first_write
sessions._last_persist -= 61.0 # simulate the throttle window elapsing
session = sessions.get(token)
assert session is not None
sessions.touch(session)
assert path.stat().st_mtime_ns != first_write
async def test_session_revoke_semantics(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token_a = sessions.generate_token()
token_b = sessions.generate_token()
token_c = sessions.generate_token()
sessions.create(token_a, "abcd2345")
sessions.create(token_b, "abcd2345")
sessions.create(token_c, "efgh6789")
sessions.revoke(token_a)
assert sessions.get(token_a) is None
assert sessions.get(token_b) is not None # siblings survive single revoke
sessions.revoke_all_for("abcd2345")
assert sessions.get(token_b) is None
assert sessions.get(token_c) is not None # other users untouched
sessions.revoke("unknown-token") # idempotent
sessions.revoke_all_for("zzzz9999")
async def test_session_store_persists_across_reload(tmp_path: Path) -> None:
path = tmp_path / "sessions.json"
sessions = SessionStore(path)
token = sessions.generate_token()
created = sessions.create(token, "abcd2345")
del created
reloaded = SessionStore(path)
assert reloaded.get(token) is not None
reloaded.revoke(token)
assert SessionStore(path).get(token) is None # revocation persisted too
# ------------------------------------------------------- corruption posture
def test_corrupt_user_store_fails_closed(tmp_path: Path) -> None:
"""A corrupt users.json must NOT silently become an empty store:
swallowing it would reopen registration on top of existing
accounts and the next persist would overwrite them. The store
must refuse to start (file moved aside for operator recovery)."""
path = tmp_path / "users.json"
path.write_text("{not json", encoding="utf-8")
with pytest.raises(RuntimeError, match="corrupt user store"):
UserStore(path)
salvage = list(tmp_path.glob("users.json.corrupt.*"))
assert salvage, "the corrupt file must be moved aside, not deleted"
assert not path.exists()
def test_corrupt_session_store_fails_toward_no_access(tmp_path: Path) -> None:
"""A corrupt sessions.json degrades to 'everyone logged out' —
the fail-safe direction for sessions (no access, not all access).
Startup must still succeed; sessions are re-created on next login."""
path = tmp_path / "sessions.json"
path.write_text("{not json", encoding="utf-8")
sessions = SessionStore(path) # must not raise
token = sessions.generate_token()
sessions.create(token, "abcd2345")
assert SessionStore(path).get(token) is not None # recovers on rewrite
+205 -1
View File
@@ -3,7 +3,8 @@ revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
"python_full_version == '3.14.*'",
"python_full_version < '3.14'",
]
[[package]]
@@ -28,6 +29,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" },
{ url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" },
{ url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" },
{ url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" },
{ url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" },
{ url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" },
{ url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" },
{ url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" },
{ url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" },
{ url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" },
{ url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" },
{ url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" },
{ url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" },
{ url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" },
{ url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" },
{ url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" },
{ url = "https://files.pythonhosted.org/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb", size = 25683, upload-time = "2026-08-20T07:33:05.337Z" },
{ url = "https://files.pythonhosted.org/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6", size = 27310, upload-time = "2026-08-20T07:33:06.344Z" },
{ url = "https://files.pythonhosted.org/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990", size = 26771, upload-time = "2026-08-20T07:33:07.429Z" },
{ url = "https://files.pythonhosted.org/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08", size = 27569, upload-time = "2026-08-20T07:33:08.598Z" },
{ url = "https://files.pythonhosted.org/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca", size = 27279, upload-time = "2026-08-20T07:33:09.518Z" },
{ url = "https://files.pythonhosted.org/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1", size = 27774, upload-time = "2026-08-20T07:33:10.728Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36", size = 26933, upload-time = "2026-08-20T07:33:11.661Z" },
{ url = "https://files.pythonhosted.org/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210", size = 24875, upload-time = "2026-08-20T07:33:12.616Z" },
{ url = "https://files.pythonhosted.org/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4", size = 26655, upload-time = "2026-08-20T07:33:13.521Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440", size = 25376, upload-time = "2026-08-20T07:33:14.491Z" },
]
[[package]]
name = "ast-serialize"
version = "0.11.2"
@@ -101,6 +157,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@@ -119,6 +260,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
]
[[package]]
name = "fastapi"
version = "0.115.14"
@@ -379,6 +570,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 +712,8 @@ name = "researchx"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "argon2-cffi" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
@@ -530,6 +732,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "argon2-cffi", specifier = ">=23.1,<26" },
{ name = "cryptography", specifier = ">=43" },
{ name = "fastapi", specifier = ">=0.115,<0.116" },
{ name = "httpx", specifier = ">=0.27,<1" },
{ name = "pydantic", specifier = ">=2.8,<3" },
+17
View File
@@ -14,6 +14,23 @@ FROM node:22-alpine AS build
RUN corepack enable
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Hosted build profile (PLAN.md T-4.3, RESEARCH-v03.md section 10): Next.js
# inlines NEXT_PUBLIC_* at BUILD time, so the profile is ARG/ENV here --
# never runtime flags. live mode + EMPTY base ("" = same-origin through the
# front proxy's /api path route): a non-empty base would break the
# same-origin session cookie behind the TLS front.
ARG NEXT_PUBLIC_RESEARCHX_API_MODE=live
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_RESEARCHX_API_MODE="${NEXT_PUBLIC_RESEARCHX_API_MODE}" \
NEXT_PUBLIC_API_BASE_URL="${NEXT_PUBLIC_API_BASE_URL}"
# Build assert: the hosted profile is hardwired -- mode=live, empty base --
# or the build fails before a wrong image can be produced.
RUN [ "$NEXT_PUBLIC_RESEARCHX_API_MODE" = "live" ] \
&& [ -z "$NEXT_PUBLIC_API_BASE_URL" ] \
|| { echo "hosted build profile violated: mode=$NEXT_PUBLIC_RESEARCHX_API_MODE" >&2; exit 1; }
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
+30
View File
@@ -66,6 +66,36 @@ hosts is wayfinding and accumulated research:
| `pnpm typecheck` | `tsc --noEmit` (TypeScript strict) |
| `pnpm test` | Vitest run (jsdom + Testing Library) |
| `pnpm test:watch` | Vitest in watch mode |
| `pnpm generate:api` | Regenerate `src/lib/api/generated/schema.d.ts` from the committed `openapi.json` snapshot (G-005 — never from a live server) |
| `pnpm check:api-drift` | Schema-drift gate: regenerates from the snapshot and diffs (exit 1 on drift — G-005) |
## Auth + sessions (v0.3 P1)
The ApiClient boundary gained the session surface (C3-18 — the
exhaustive list): `login` / `logout` / `getSession` plus the admin
hooks `listUsers` / `createUser` / `deactivateUser`.
- **Mock mode** (the default): a standalone dev fixture — login accepts
any non-empty credentials and swaps an in-memory session (the dev
admin `dev00000`), admin hooks return small fixture arrays, and the
app is never gated behind a login (REQ-31/REQ-54).
- **Live mode** (`NEXT_PUBLIC_RESEARCHX_API_MODE=live`): real routes —
`POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`
(401 → null), `GET /api/admin/users`, `POST /api/admin/users`,
`POST /api/admin/users/{user_id}/deactivate`. Every mutating request
carries the `X-ResearchX-Request` CSRF header (C3-05) and every
request sends `credentials: "include"` so the httpOnly session
cookie rides along. A failed login rejects with a typed `AuthError`
carrying the backend's uniform 401 copy.
The live-mode route guard (`SessionGate` in the root layout) bootstraps
the session on load: an unauthenticated visitor is redirected to
`/login` before any protected content renders; the login screen itself
is never gated. Mock mode skips the check entirely — same components,
zero mock-mode behavior change. Dev-live origins must share the
`localhost` hostname on both sides (`http://localhost:8000` backend +
`http://localhost:3000` frontend — the port is not part of the
same-site computation).
## Architecture notes
+1019 -1
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -10,7 +10,9 @@
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"generate:api": "openapi-typescript ./openapi.json -o src/lib/api/generated/schema.d.ts",
"check:api-drift": "openapi-typescript ./openapi.json -o src/lib/api/generated/schema.d.ts --check"
},
"dependencies": {
"next": "15.5.6",
+13 -6
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "./globals.css";
import { AppSidebar } from "@/components/app-sidebar";
import { AppTopBar } from "@/components/app-top-bar";
import { SessionGate } from "@/components/session-gate";
/**
* Root layout (T-5.2, REQ-63) — the persistent app shell.
@@ -10,6 +11,10 @@ import { AppTopBar } from "@/components/app-top-bar";
* slots) and top bar (⌘K palette + avatar). The shell adds zero
* required clicks to the happy path — it is pure wayfinding chrome.
* Page content lands in one <main> landmark per route (a11y).
*
* v0.3 P1 (T-1.10): the SessionGate wraps the shell in live mode —
* an unauthenticated visitor is redirected to /login before any
* protected content renders. Mock mode passes straight through.
*/
export const metadata: Metadata = {
@@ -26,13 +31,15 @@ export default function RootLayout({
return (
<html lang="en">
<body>
<div className="flex min-h-dvh">
<AppSidebar />
<div className="flex min-w-0 flex-1 flex-col">
<AppTopBar />
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
<SessionGate>
<div className="flex min-h-dvh">
<AppSidebar />
<div className="flex min-w-0 flex-1 flex-col">
<AppTopBar />
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
</div>
</div>
</div>
</SessionGate>
</body>
</html>
);
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { LoginView } from "@/components/login-view";
/**
* /login (T-1.10, REQ-69) — the session entry point in live mode.
* Live-mode users land here via the app-shell guard when no session
* exists; mock mode never routes here (no auth — REQ-31).
*/
export const metadata: Metadata = {
title: "Sign in — ResearchX",
};
export default function LoginPage() {
return <LoginView />;
}
@@ -180,4 +180,19 @@ describe("UserMenu (REQ-66)", () => {
expect(await screen.findByText("Researcher")).toBeInTheDocument();
expect(screen.getByText("Local workspace — single user")).toBeInTheDocument();
});
it("logout calls the boundary then routes to /login (T-1.10, REQ-69)", async () => {
const user = userEvent.setup();
const onDismiss = vi.fn();
render(
<UserMenu onNavigate={() => {}} onDismiss={onDismiss} />,
);
const logout = await screen.findByRole("menuitem", { name: /Log out/i });
expect(logout).toBeInTheDocument();
await user.click(logout);
await vi.waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
expect(onDismiss).toHaveBeenCalled();
});
});
});
+2 -2
View File
@@ -14,8 +14,8 @@ import { usePathname, useRouter } from "next/navigation";
import { forwardRef, useEffect, useRef, useState } from "react";
import {
api,
type DisplayProfile,
type ReportLibrary,
type UserProfile,
} from "@/lib/api/client";
import { CommandPalette, type PaletteEntry } from "@/components/command-palette";
import { UserMenu } from "@/components/user-menu";
@@ -121,7 +121,7 @@ const AvatarButton = forwardRef<HTMLButtonElement, {
menuOpen: boolean;
onToggle: () => void;
}>(function AvatarButton({ menuOpen, onToggle }, ref) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [profile, setProfile] = useState<DisplayProfile | null>(null);
useEffect(() => {
let cancelled = false;
api
+28 -3
View File
@@ -19,6 +19,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import {
api,
liveMode,
type InboxItem,
type ProjectGraph,
type ReportConnection,
@@ -424,10 +425,19 @@ function ProjectGraphPanel({ graph }: { graph: ProjectGraph }) {
/* Task inbox panel (REQ-60) */
/* ------------------------------------------------------------------ */
/**
* G-109 (task-inbox honesty): in LIVE mode the status is DERIVED
* server-side (answered-iff the source run reached DONE — D-114), so
* the checkbox is display-honest — disabled and annotated, never a
* free toggle that silently reverts on the next fetch. Persisted
* per-question check state is v0.4+ (REQ-45 backlog). MOCK mode keeps
* the free toggle byte-for-byte (the dev fixture owns its state).
*/
function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
const [items, setItems] = useState(initialItems);
const toggle = (id: string) => {
if (liveMode) return; // display-honest: live status is derived, not local
setItems((current) =>
current.map((item) =>
item.id === id
@@ -452,9 +462,16 @@ function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
<p className="mt-1 text-caption text-ink-soft">
Open questions worth following up · {openCount} open
</p>
<p className="mt-1 text-caption text-ink-faint">
Toggles are local-only — the inbox persists in v0.2.
</p>
{liveMode ? (
<p className="mt-1 text-caption text-ink-faint" data-testid="inbox-derived-note">
A question is answered when its research run completes — status
follows the run.
</p>
) : (
<p className="mt-1 text-caption text-ink-faint">
Toggles are local-only — the inbox persists in v0.2.
</p>
)}
<ul className="mt-4 space-y-2">
{items.map((item) => (
@@ -463,7 +480,15 @@ function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
type="checkbox"
checked={item.status === "done"}
onChange={() => toggle(item.id)}
// G-109: derived status in live mode — disabled, never a
// silent revert; mock keeps the free toggle.
disabled={liveMode}
aria-label={item.question}
title={
liveMode
? "Follows its research run — answered when the run completes"
: undefined
}
className="mt-1 h-4 w-4 accent-[var(--color-accent)]"
/>
<span
@@ -0,0 +1,374 @@
/**
* Live-mode component tests (T-3.9, G-109; REQ-68, REQ-80).
*
* The previously-delegated surfaces driving REAL components in live
* mode — no boundary mocks, the ApiClient surface only:
* - G-109: the task-inbox checkbox is DISPLAY-HONEST in live mode —
* derived status (open-iff run not DONE), disabled + annotated;
* a click never flips it; the mock keeps the free toggle (pinned
* by the unchanged library-view suite).
* - Refine honesty: a 503 from the real planner endpoint surfaces
* the error state — never a hang, never synthetic questions.
*
* House pattern (user-menu-session suite): the module's `api` +
* `liveMode` exports are swapped per case through module
* interception; the components themselves are UNTOUCHED.
*/
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AskView } from "@/components/ask-view";
import { LibraryView } from "@/components/library-view";
import type {
InboxItem,
ProjectGraph,
ReportConnection,
ReportLibrary,
} from "@/lib/api/client";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
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;
},
},
),
get liveMode() {
return state.liveMode;
},
};
});
/* ------------------------------------------------------------------ */
/* Live-mode boundary stub — payloads verbatim from the read-models */
/* ------------------------------------------------------------------ */
const LIVE_LIBRARY: ReportLibrary = {
folders: [
{
id: "deep-report-battery",
name: "Deep Report: Battery",
description: "Auto-filed from deep report battery research.",
},
],
reports: [
{
id: "rep-runaaaa1",
runId: "runaaaa1",
title: "Battery Advances",
question: "What about battery research?",
methodologySlug: "deep-report",
category: "deep-report-battery",
generatedAt: "2026-09-20T10:05:00Z",
citationCount: 1,
},
],
};
const LIVE_CONNECTIONS: ReportConnection[] = [
{
fromReportId: "rep-runaaaa1",
toReportId: "rep-runbbbb1",
kind: "shared-sources",
label: "2 shared sources",
},
];
const LIVE_GRAPH: ProjectGraph = {
nodes: [
{
id: "runaaaa1",
topic: "Battery Advances",
linkedSources: ["https://example.org/x"],
findings: ["Battery Advances"],
},
],
edges: [],
};
const LIVE_INBOX: InboxItem[] = [
{
id: "runaaaa1#0",
question: "What is the research question?",
sourceRunId: "runaaaa1",
status: "open",
},
{
id: "runbbbb1#0",
question: "An answered question",
sourceRunId: "runbbbb1",
status: "done",
},
];
/** A live-shaped ApiClient surface for the library panels. */
function liveLibraryApi(): Record<string, unknown> {
return {
getReportLibrary: vi.fn().mockResolvedValue(LIVE_LIBRARY),
getReportConnections: vi.fn().mockResolvedValue(LIVE_CONNECTIONS),
getProjectGraph: vi.fn().mockResolvedValue(LIVE_GRAPH),
getTaskInbox: vi.fn().mockResolvedValue(LIVE_INBOX),
};
}
beforeEach(() => {
pushMock.mockClear();
});
afterEach(cleanup);
/* ------------------------------------------------------------------ */
/* G-109 — the task-inbox checkbox is display-honest in live mode */
/* ------------------------------------------------------------------ */
describe("LibraryView task inbox — live-mode honesty (G-109)", () => {
it("renders every live surface: folders, connections, graph, tasks", async () => {
state.api = liveLibraryApi();
state.liveMode = true;
render(<LibraryView />);
// Library feeds from the LIVE read-model payload (T-3.9).
const card = await screen.findByRole("link", { name: /Battery Advances/i });
expect(card).toHaveAttribute("href", "/run/runaaaa1/report");
expect(screen.getByRole("button", { name: /Deep Report: Battery/i })).toBeInTheDocument();
expect(screen.getByText(/1 citation/i)).toBeInTheDocument();
expect(
screen.getByText(/Shared sources — 2 shared sources/i),
).toBeInTheDocument();
// The graph panel renders the run as a node (topic = report title).
const graph = await screen.findByRole("region", { name: "Project graph" });
expect(graph.textContent).toContain("Battery Advances");
});
it("live checkboxes are DISABLED and the derived-status note shows", async () => {
state.api = liveLibraryApi();
state.liveMode = true;
render(<LibraryView />);
await screen.findByRole("region", { name: "Task inbox" });
const open = await screen.findByRole("checkbox", {
name: "What is the research question?",
});
const done = await screen.findByRole("checkbox", {
name: "An answered question",
});
// Derived status rendered — open stays open, done stays done.
expect(open).not.toBeChecked();
expect(done).toBeChecked();
// G-109: display-honest — both disabled.
expect(open).toBeDisabled();
expect(done).toBeDisabled();
// Annotated, never silent.
expect(
screen.getByTestId("inbox-derived-note"),
).toHaveTextContent(/answered when its research run completes/i);
// The open count reflects the server-derived statuses.
expect(
screen.getByText(/open questions worth following up · 1 open/i),
).toBeInTheDocument();
});
it("a click on a live checkbox never flips the derived status", async () => {
state.api = liveLibraryApi();
state.liveMode = true;
const user = userEvent.setup();
render(<LibraryView />);
const open = await screen.findByRole("checkbox", {
name: "What is the research question?",
});
await user.click(open);
// Still unchecked — no silent revert because nothing ever changed.
expect(open).not.toBeChecked();
expect(
screen.getByText(/open questions worth following up · 1 open/i),
).toBeInTheDocument();
});
it("mock mode keeps the free toggle byte-for-byte (G-109 dev fixture)", async () => {
state.api = liveLibraryApi();
state.liveMode = false;
const user = userEvent.setup();
render(<LibraryView />);
const open = await screen.findByRole("checkbox", {
name: "What is the research question?",
});
expect(open).toBeEnabled();
expect(
screen.queryByTestId("inbox-derived-note"),
).toBeNull();
await user.click(open);
expect(open).toBeChecked();
expect(
screen.getByText(/open questions worth following up · 0 open/i),
).toBeInTheDocument();
});
});
/* ------------------------------------------------------------------ */
/* Refine — the real exchange with an honest 503 (T-3.6, REQ-80) */
/* ------------------------------------------------------------------ */
describe("RefineDrawer — live-mode 503 honesty", () => {
it("surfaces the honest unavailable state — never a hang, never synthetic", async () => {
state.api = {
getSuggestedQueries: vi.fn().mockResolvedValue([]),
getRefinementQuestions: vi
.fn()
.mockRejectedValue(new Error("live: refine is unavailable — 503")),
composeRefinedQuestion: vi.fn(),
};
state.liveMode = true;
const user = userEvent.setup();
render(<AskView />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"What are the latest advances in solid-state batteries",
);
await user.click(screen.getByRole("button", { name: /Refine question/i }));
expect(
await screen.findByRole("region", { name: /Refine your question/i }),
).toBeInTheDocument();
// The honest error state — the drawer never invents questions.
expect(
await screen.findByRole("alert"),
).toHaveTextContent(/Refine is unavailable right now/i);
expect(
screen.queryByLabelText(/matters most to you/i),
).toBeNull();
// Keep original closes the drawer with the draft untouched.
await user.click(screen.getByRole("button", { name: "Keep original" }));
expect(
screen.queryByRole("region", { name: /Refine your question/i }),
).toBeNull();
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
"What are the latest advances in solid-state batteries",
);
});
it("drives the REAL live exchange: model questions → model composition", async () => {
state.api = {
getSuggestedQueries: vi.fn().mockResolvedValue([]),
getRefinementQuestions: vi.fn().mockResolvedValue([
{
id: "q-1",
prompt: "Which application domain matters most?",
placeholder: "one short answer",
optional: false,
},
{
id: "q-2",
prompt: "What decision will this research inform?",
placeholder: "one short answer",
optional: true,
},
]),
composeRefinedQuestion: vi
.fn()
.mockResolvedValue({
question: "Solid-state batteries — automotive applications",
context: "Feeds a supplier choice.",
}),
};
state.liveMode = true;
const user = userEvent.setup();
render(<AskView />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"What are the latest advances in solid-state batteries",
);
await user.click(screen.getByRole("button", { name: /Refine question/i }));
const q1 = await screen.findByLabelText(/Which application domain matters most\?/i);
await user.type(q1, "automotive applications");
await user.click(screen.getByRole("button", { name: "Next" }));
const q2 = await screen.findByLabelText(/What decision will this research inform\?/i);
await user.type(q2, "choosing a supplier");
await user.click(screen.getByRole("button", { name: "Compose question" }));
// The model-composed refinement arrives and fills the form.
expect(
await screen.findByText("Solid-state batteries — automotive applications"),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Use this" }));
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
"Solid-state batteries — automotive applications",
);
expect(screen.getByLabelText(/anything else it should know\?/i)).toHaveValue(
"Feeds a supplier choice.",
);
});
it("live suggestions render with source attribution and never auto-start", async () => {
state.api = {
getSuggestedQueries: vi.fn().mockResolvedValue([
{
id: "sug-runcccc1#0",
question: "What is the research question?",
sourceRunId: "runcccc1",
sourceReportTitle: "Deep Report — an in-flight run",
},
]),
getRefinementQuestions: vi.fn(),
composeRefinedQuestion: vi.fn(),
};
state.liveMode = true;
const user = userEvent.setup();
render(<AskView />);
const card = await screen.findByRole("button", {
name: /What is the research question\?/i,
});
expect(
screen.getByText(/From your research — Deep Report — an in-flight run/i),
).toBeInTheDocument();
await user.click(card);
// Click-fills the box — never auto-starts.
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
"What is the research question?",
);
expect(pushMock).not.toHaveBeenCalled();
});
it("a fresh user sees the honest empty suggestions state in live mode", async () => {
state.api = {
getSuggestedQueries: vi.fn().mockResolvedValue([]),
getRefinementQuestions: vi.fn(),
composeRefinedQuestion: vi.fn(),
};
state.liveMode = true;
render(<AskView />);
await waitFor(() => {
expect(
screen.getByText(/No open questions yet — they collect here as you run research/i),
).toBeInTheDocument();
});
});
});
+130
View File
@@ -0,0 +1,130 @@
/**
* LoginView tests (T-1.10, REQ-69).
*
* - Renders the email + password form with a disabled submit until both
* fields are filled
* - Submit routes through the boundary: success → /ask; failure → the
* uniform "invalid email or password" error state (401, no
* user enumeration)
*
* The house pattern mocks NO boundary — instead this suite injects a
* boundary-shaped stub through module interception because login is
* the one call whose FAILURE path must be driven deterministically
* (the mock binding never fails a login). The stub speaks only the
* ApiClient surface.
*/
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LoginView } from "@/components/login-view";
import { AuthError, type ApiClient } from "@/lib/api/client";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
usePathname: () => "/login",
}));
/** A boundary-shaped stub driving success/failure of login (REQ-69). */
const loginMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/client", async (importOriginal) => {
const actual = await importOriginal<
Promise<typeof import("@/lib/api/client")>
>();
return {
...actual,
api: {
...(actual.api as unknown as ApiClient),
login: (...args: [string, string]) => loginMock(...args),
} as unknown as ApiClient,
};
});
const SESSION_USER = {
user_id: "abcd2345",
email: "owner@example.org",
display_name: "Owner",
role: "admin",
active: true,
created_at: "2026-09-29T12:00:00Z",
};
describe("LoginView (REQ-69)", () => {
beforeEach(() => {
pushMock.mockClear();
loginMock.mockClear();
});
afterEach(cleanup);
it("renders email + password fields and a disabled submit until both are set", async () => {
const user = userEvent.setup();
render(<LoginView />);
const email = screen.getByLabelText("Email");
const password = screen.getByLabelText("Password");
const submit = screen.getByRole("button", { name: "Sign in" });
expect(submit).toBeDisabled();
await user.type(email, "owner@example.org");
expect(submit).toBeDisabled(); // password still empty
await user.type(password, "not-a-real-secret");
expect(submit).toBeEnabled();
});
it("submits through the boundary and redirects to /ask on success", async () => {
loginMock.mockResolvedValueOnce(SESSION_USER);
const user = userEvent.setup();
render(<LoginView />);
await user.type(screen.getByLabelText("Email"), "owner@example.org");
await user.type(
screen.getByLabelText("Password"),
"correct-horse-battery",
);
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(loginMock).toHaveBeenCalledWith(
"owner@example.org",
"correct-horse-battery",
);
await vi.waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/ask");
});
});
it("shows the uniform 'invalid email or password' error on a rejected login", async () => {
// The backend answers every failure mode uniformly (REQ-69).
loginMock.mockRejectedValueOnce(new AuthError("invalid email or password", 401));
const user = userEvent.setup();
render(<LoginView />);
await user.type(screen.getByLabelText("Email"), "owner@example.org");
await user.type(screen.getByLabelText("Password"), "wrong-password");
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"invalid email or password",
);
expect(pushMock).not.toHaveBeenCalled();
// The submit re-enables for a retry.
expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled();
});
it("surfaces an honest unavailable state on a non-auth failure", async () => {
loginMock.mockRejectedValueOnce(new Error("network down"));
const user = userEvent.setup();
render(<LoginView />);
await user.type(screen.getByLabelText("Email"), "owner@example.org");
await user.type(screen.getByLabelText("Password"), "whatever");
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"sign-in unavailable",
);
expect(pushMock).not.toHaveBeenCalled();
});
});
+109
View File
@@ -0,0 +1,109 @@
"use client";
/**
* LoginView (T-1.10, REQ-69) — the /login screen client.
*
* Email + password against POST /api/auth/login through the ApiClient
* boundary. On success the app lands on /ask (the front door); on 401
* the error is the backend's uniform "invalid email or password" — no
* user enumeration, client-side included. A first-run hint points at
* the backend's register-once rule (the first user is admin).
*/
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { api, AuthError } from "@/lib/api/client";
export function LoginView() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = email.trim() !== "" && password !== "" && !submitting;
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
api
.login(email.trim(), password)
.then(() => {
router.push("/ask");
})
.catch((err: unknown) => {
// Uniform 401 copy — the backend never says WHICH half failed.
if (err instanceof AuthError) {
setError("invalid email or password");
} else {
setError("sign-in unavailable — try again");
}
setSubmitting(false);
});
}
return (
<div className="mx-auto flex max-w-md flex-col px-6 py-section">
<header className="mb-block">
<h1 className="text-display font-semibold text-ink">Sign in.</h1>
<p className="measure-reading mt-4 text-lead text-ink-soft">
ResearchX runs under your account — your research library,
reports, and settings are yours alone.
</p>
</header>
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email" className="block text-body font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
placeholder="you@example.org"
className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
/>
<label htmlFor="password" className="mt-6 block text-body font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••••••"
className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
/>
{error !== null && (
<p role="alert" className="mt-4 text-small text-danger">
{error}
</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="mt-8 rounded-pill bg-accent px-7 py-3 text-body font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? "Signing in…" : "Sign in"}
</button>
</form>
<p className="mt-block text-caption text-ink-faint">
First run? The first account created becomes the administrator.
</p>
</div>
);
}
@@ -0,0 +1,439 @@
/**
* Reading Room live parity + honest exports (T-3.10, REQ-82, REQ-59,
* REQ-34 — P-12 closure pins).
*
* The REAL ReadingRoom component rendering a REAL live payload —
* the enriched GET /api/runs/{id}/report envelope (report.json +
* evidence.json + markdown + ledger) stubbed at fetch level:
* - structured sections render with [^pN] markers (zero
* report-body.ts changes — C3-10);
* - hover/focus on a marker reveals the EXACT stored quoted passage
* (parity with the mock Reading Room, REQ-82);
* - the ledger panel lists real entries with real quotes;
* - exports carry REAL data: markdown from the served markdown,
* JSON with real claims_supported/health/tier/quotes — the
* fabricated claims_supported:1 and empty quotes are DEAD, and a
* test pins their absence (P-12).
*
* House pattern (user-menu-session suite): the module's `api` export
* is swapped per case through a hoisted module interception backed by
* a GeneratedApiClient over a stubbed fetch — the components
* themselves are UNTOUCHED, and every call still speaks only the
* ApiClient surface.
*/
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ReadingRoom } from "@/components/reading-room";
import { GeneratedApiClient } from "@/lib/api/live-client";
const state = vi.hoisted(() => ({
api: {} as Record<string, unknown>,
}));
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;
},
},
),
};
});
/* ------------------------------------------------------------------ */
/* Fixture live payload — the DONE-run envelope, verbatim shapes */
/* ------------------------------------------------------------------ */
const RUN_ID = "runaaaa1";
const FIXTURE_ENVELOPE = {
run_id: RUN_ID,
markdown:
"# Battery Advances\n\nThe verified core finding [p-1]. A second corroborated observation [p-2].\n",
report: {
v: 1,
run_id: RUN_ID,
owner_id: "abcd2345",
title: "Battery Advances",
question: "What about battery research?",
methodology_slug: "deep-report",
generated_at: "2026-09-20T10:05:00Z",
sections: [
{
heading: "Research Memo",
body: "The verified core finding [^p1]. A second corroborated observation [^p2].",
},
],
citations: [
{
passage_id: "p1",
url: "https://example.org/x",
title: "Verification passes",
quote: "The verified core finding.",
health: "live",
authority_tier: "official",
},
{
passage_id: "p2",
url: "https://example.com/y",
title: "Quote anchoring and auditability",
quote: "Anchoring makes auditability real.",
health: "stale",
authority_tier: "general",
},
],
},
evidence: {
v: 1,
run_id: RUN_ID,
passages: [
{
passage_id: "p1",
url: "https://example.org/x",
title: "Verification passes",
quote: "The verified core finding.",
text: "The verified core finding in full, stored at DONE.",
},
{
passage_id: "p2",
url: "https://example.com/y",
title: "Quote anchoring and auditability",
quote: "Anchoring makes auditability real.",
text: "Anchoring is the mechanism that makes auditability real.",
},
],
},
};
const FIXTURE_LEDGER = {
run_id: RUN_ID,
generated_at: "2026-09-20T10:05:00Z",
entries: [
{
url: "https://example.org/x",
visited_at: "2026-09-20T10:04:00Z",
passages: ["p-1"],
claims_supported: 2,
health: "live",
authority_tier: "official",
},
{
url: "https://example.com/y",
visited_at: "2026-09-20T10:04:30Z",
passages: ["p-2"],
claims_supported: 1,
health: "stale",
authority_tier: "general",
},
],
checklist: {
run_id: RUN_ID,
citations_checked: 2,
live: 1,
stale: 1,
dead: 0,
},
};
/** A fetch stub serving the DONE-run report envelope + ledger. */
function liveStackFetch(): typeof globalThis.fetch {
const routes: Record<string, unknown> = {
[`http://127.0.0.1:8000/api/runs/${RUN_ID}/report`]: FIXTURE_ENVELOPE,
[`http://127.0.0.1:8000/api/runs/${RUN_ID}/ledger`]: FIXTURE_LEDGER,
};
return (async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
for (const [prefix, body] of Object.entries(routes)) {
if (url.startsWith(prefix)) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
}
return new Response(JSON.stringify({ detail: "not found" }), { status: 404 });
}) as typeof globalThis.fetch;
}
/** The live client over the stubbed stack — the boundary per case. */
function liveApi(): Record<string, unknown> {
const client = new GeneratedApiClient(liveStackFetch());
return client as unknown as Record<string, unknown>;
}
beforeEach(() => {
state.api = liveApi();
});
afterEach(cleanup);
/* ------------------------------------------------------------------ */
/* Component parity — the REAL ReadingRoom over the live payload */
/* ------------------------------------------------------------------ */
describe("ReadingRoom live parity (T-3.10, REQ-82)", () => {
it("renders structured sections + [^pN] markers + the real title", async () => {
render(<ReadingRoom id={RUN_ID} />);
// The REAL report.json title — not a placeholder.
expect(await screen.findByText("Battery Advances")).toBeInTheDocument();
// Structured section heading from report.json.
expect(screen.getByText("Research Memo")).toBeInTheDocument();
// Section body text renders around the parsed [^pN] markers —
// both text segments of the memo section are present.
const memo = screen.getByText("Research Memo").closest("section");
expect(memo?.textContent).toContain("The verified core finding ");
expect(memo?.textContent).toContain("A second corroborated observation");
// Markers render as citation buttons (marker index from pN).
expect(
screen.getAllByRole("button", { name: /Citation 1:/i }).length,
).toBeGreaterThan(0);
expect(
screen.getAllByRole("button", { name: /Citation 2:/i }).length,
).toBeGreaterThan(0);
});
it("hover/focus reveals the EXACT stored quoted passage (REQ-82 parity)", async () => {
render(<ReadingRoom id={RUN_ID} />);
const markers = await screen.findAllByRole("button", { name: /Citation 1:/i });
markers[0]?.focus();
const tooltip = await screen.findByRole("tooltip");
// The stored passage quote — exactly what the engine anchored.
expect(tooltip.textContent).toContain("The verified core finding.");
expect(tooltip.textContent).toContain("Verification passes");
expect(tooltip.textContent).toContain("example.org");
// Tier map: official → 1.
expect(tooltip.textContent).toContain("tier 1");
});
it("the ledger panel lists the REAL entries with quotes + health", async () => {
render(<ReadingRoom id={RUN_ID} />);
expect(await screen.findByText("Citation ledger")).toBeInTheDocument();
expect(
screen.getByText("2 sources · every claim traceable to a visited passage"),
).toBeInTheDocument();
// Real entry titles + real quotes.
expect(screen.getByText("Verification passes")).toBeInTheDocument();
expect(
screen.getByText("Quote anchoring and auditability"),
).toBeInTheDocument();
expect(screen.getByText(/“The verified core finding\.”/)).toBeInTheDocument();
// Health badges from the real ledger health values.
expect(screen.getAllByText(/live/i).length).toBeGreaterThan(0);
expect(screen.getByText(/stale/i)).toBeInTheDocument();
});
it("markdown-only fallback renders honestly for a pre-artifact DONE run", async () => {
// Swap in a pre-artifact envelope: markdown only, null artifacts —
// with a ledger still served (the run reached DONE and the ledger
// predates report.json; the route never 500s — D-106).
const preArtifact = new GeneratedApiClient(
(async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
if (url.includes("/report")) {
return new Response(
JSON.stringify({
run_id: "runbbbb1",
markdown: "# Old Report\n\nRaw [p-1] markers only.",
report: null,
evidence: null,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url.includes("/ledger")) {
return new Response(
JSON.stringify({
run_id: "runbbbb1",
generated_at: "2026-09-19T10:05:00Z",
entries: [],
checklist: { run_id: "runbbbb1", citations_checked: 0 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(JSON.stringify({ detail: "not found" }), {
status: 404,
});
}) as typeof globalThis.fetch,
);
state.api = preArtifact as unknown as Record<string, unknown>;
render(<ReadingRoom id="runbbbb1" />);
// The served markdown, verbatim — never a 500, never fabricated
// sections or citations.
expect(await screen.findByText("Report")).toBeInTheDocument();
expect(screen.getByText(/Raw \[p-1\] markers only\./i)).toBeInTheDocument();
// No citation markers as buttons — there are no citations.
expect(screen.queryByRole("button", { name: /Citation/i })).toBeNull();
});
});
/* ------------------------------------------------------------------ */
/* Honest exports — P-12 closure pins (boundary level) */
/* ------------------------------------------------------------------ */
describe("exportReport — real data, P-12 fabrication dead (T-3.10, REQ-34)", () => {
it("markdown export carries the SERVED markdown + a real-quote citation block", async () => {
const client = new GeneratedApiClient(liveStackFetch());
const markdown = await client.exportReport(RUN_ID, "markdown");
expect(markdown.filename).toBe(`researchx-${RUN_ID}.md`);
// The served report.md verbatim — raw [p-N] markers intact (C3-10).
expect(markdown.content).toContain("# Battery Advances");
expect(markdown.content).toContain("The verified core finding [p-1].");
// Citations block: real URLs as links + real health/tier.
expect(markdown.content).toContain("](https://example.org/x)");
expect(markdown.content).toContain("✓ live");
expect(markdown.content).toContain("△ stale");
// The REAL stored quote appears in the citation block.
expect(markdown.content).toContain("The verified core finding.");
});
it("JSON export carries REAL claims_supported/health/tier/quotes per entry", async () => {
const client = new GeneratedApiClient(liveStackFetch());
const json = await client.exportReport(RUN_ID, "json");
expect(json.filename).toBe(`researchx-${RUN_ID}.json`);
const parsed = JSON.parse(json.content) as {
run_id: string;
generated_at: string;
entries: Array<{
url: string;
visited_at: string;
passages: string[];
claims_supported: number;
health: string;
authority_tier: string;
quote: string;
}>;
checklist: Record<string, unknown>;
};
expect(parsed.run_id).toBe(RUN_ID);
expect(parsed.entries).toHaveLength(2);
// Entry 0: the REAL values from ledger.json — claims_supported 2
// (NOT the fabricated 1), live health, official tier.
expect(parsed.entries[0]?.url).toBe("https://example.org/x");
expect(parsed.entries[0]?.claims_supported).toBe(2);
expect(parsed.entries[0]?.health).toBe("live");
expect(parsed.entries[0]?.authority_tier).toBe("official");
// Entry 1 keeps its own real values — the entries are not clones.
expect(parsed.entries[1]?.claims_supported).toBe(1);
expect(parsed.entries[1]?.health).toBe("stale");
// The REAL stored quotes — never empty strings.
expect(parsed.entries[0]?.quote).toBe("The verified core finding.");
expect(parsed.entries[1]?.quote).toBe("Anchoring makes auditability real.");
// The real checklist from the ledger artifact.
expect(parsed.checklist).toEqual(FIXTURE_LEDGER.checklist);
});
it("P-12 pin: the fabricated claims_supported:1 and empty quotes are ABSENT", async () => {
const client = new GeneratedApiClient(liveStackFetch());
const json = await client.exportReport(RUN_ID, "json");
// Every entry carries its OWN claims count — an all-1s dump would
// mean the fabrication is back (entry 0's real count is 2).
const claims = JSON.parse(json.content) as {
entries: Array<{ claims_supported: number; quote: string }>;
};
expect(claims.entries.map((e) => e.claims_supported)).toEqual([2, 1]);
// No empty quotes anywhere.
for (const entry of claims.entries) {
expect(entry.quote.length).toBeGreaterThan(0);
}
// And the export never emits the mock-era placeholder title either.
expect(json.content).not.toContain('"Research Report"');
});
});
/* ------------------------------------------------------------------ */
/* getReport/getLedger mappings — the boundary contract */
/* ------------------------------------------------------------------ */
describe("getReport/getLedger live mappings (T-3.10)", () => {
it("getReport maps report.json → Report with real quotes + the tier map", async () => {
const client = new GeneratedApiClient(liveStackFetch());
const report = await client.getReport(RUN_ID);
expect(report.runId).toBe(RUN_ID);
expect(report.title).toBe("Battery Advances");
expect(report.sections).toEqual([
{
heading: "Research Memo",
body: "The verified core finding [^p1]. A second corroborated observation [^p2].",
},
]);
// [^pN] markers survive byte-for-byte — zero report-body.ts changes.
expect(report.sections[0]?.body).toMatch(/\[\^p1\]/);
expect(report.citations).toHaveLength(2);
// Tier map: official→1, general→3.
expect(report.citations[0]).toEqual({
id: "c-1",
url: "https://example.org/x",
title: "Verification passes",
site: "example.org",
passageId: "p1",
quote: "The verified core finding.",
verification: "live",
authorityTier: 1,
});
expect(report.citations[1]?.authorityTier).toBe(3);
expect(report.citations[1]?.site).toBe("example.com");
expect(report.citations[1]?.verification).toBe("stale");
});
it("getLedger maps real entries with pN-normalized ids + stored quotes", async () => {
const client = new GeneratedApiClient(liveStackFetch());
const ledger = await client.getLedger(RUN_ID);
expect(ledger.runId).toBe(RUN_ID);
expect(ledger.generatedAt).toBe("2026-09-20T10:05:00Z");
expect(ledger.citations).toHaveLength(2);
// ledger.json ids are p-N; the Citation passageId is the pN form
// the [^pN] markers parse to.
expect(ledger.citations[0]?.passageId).toBe("p1");
expect(ledger.citations[0]?.quote).toBe("The verified core finding.");
expect(ledger.citations[0]?.authorityTier).toBe(1);
expect(ledger.citations[1]?.passageId).toBe("p2");
expect(ledger.citations[1]?.verification).toBe("stale");
});
it("the report + ledger share one network read per DONE run (memoized envelope)", async () => {
let reportCalls = 0;
const counting = new GeneratedApiClient(
(async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
if (url.includes("/report")) {
reportCalls += 1;
return new Response(JSON.stringify(FIXTURE_ENVELOPE), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url.includes("/ledger")) {
return new Response(JSON.stringify(FIXTURE_LEDGER), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ detail: "not found" }), {
status: 404,
});
}) as typeof globalThis.fetch,
);
await counting.getReport(RUN_ID);
await counting.getLedger(RUN_ID);
await counting.exportReport(RUN_ID, "markdown");
// report route hit exactly ONCE: the envelope is memoized per run
// once report.json exists (a terminal run's artifacts are final).
expect(reportCalls).toBe(1);
});
});
@@ -0,0 +1,101 @@
/**
* SessionGate tests (T-1.10, REQ-69) — the live-mode route guard.
*
* The gate consults the boundary's getSession() on mount:
* - live mode + no session → redirect to /login
* - live mode + session → content renders
* - live mode + /login route → never gated (the login screen itself)
* - mock mode → no getSession call at all, content
* renders immediately (REQ-31/REQ-54)
*
* Mode is a module constant resolved from env at import time, so this
* suite exercises the gate's decision logic with the boundary stubbed
* per case; the mock-mode fast path is pinned against the REAL
* binding (the default mock singleton).
*/
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionGate } from "@/components/session-gate";
import type { ApiClient } from "@/lib/api/client";
const replaceMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: replaceMock }),
usePathname: () => "/ask",
}));
const getSessionMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/client", async (importOriginal) => {
const actual = await importOriginal<
Promise<typeof import("@/lib/api/client")>
>();
return {
...actual,
liveMode: true, // the gate only acts in live mode
api: {
...(actual.api as unknown as ApiClient),
getSession: () => getSessionMock(),
} as unknown as ApiClient,
};
});
const SESSION_USER = {
user_id: "abcd2345",
email: "owner@example.org",
display_name: "Owner",
role: "admin",
active: true,
created_at: "2026-09-29T12:00:00Z",
};
describe("SessionGate (T-1.10, REQ-69)", () => {
beforeEach(() => {
replaceMock.mockClear();
getSessionMock.mockReset();
});
afterEach(cleanup);
it("redirects an unauthenticated visitor to /login before content renders", async () => {
// Persistent mock: React StrictMode double-invokes effects in tests,
// so the boundary stub must answer every call the same way.
getSessionMock.mockResolvedValue(null);
render(
<SessionGate>
<p>Protected screen</p>
</SessionGate>,
);
// Guard content never flashes for an unauthenticated visitor.
expect(screen.queryByText("Protected screen")).toBeNull();
await vi.waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/login");
});
expect(getSessionMock).toHaveBeenCalled();
});
it("renders the shell once a session resolves", async () => {
getSessionMock.mockResolvedValue(SESSION_USER);
render(
<SessionGate>
<p>Protected screen</p>
</SessionGate>,
);
expect(await screen.findByText("Protected screen")).toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalled();
});
it("fails closed when the session probe errors", async () => {
getSessionMock.mockRejectedValue(new Error("backend unreachable"));
render(
<SessionGate>
<p>Protected screen</p>
</SessionGate>,
);
await vi.waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/login");
});
expect(screen.queryByText("Protected screen")).toBeNull();
});
});
+59
View File
@@ -0,0 +1,59 @@
"use client";
/**
* SessionGate (T-1.10, REQ-69) — the live-mode route guard.
*
* A session bootstrap in the app shell: on mount it asks the boundary
* for the current session; in live mode an unauthenticated user is
* redirected to /login. Mock mode skips the check entirely (the mock
* is a standalone dev/test fixture with no auth — REQ-31/REQ-54), so
* its behavior stays byte-for-byte unchanged.
*
* While the session resolves the screen is blank rather than flashing
* protected content at an unauthenticated visitor (no data ever
* renders before the guard clears).
*/
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { api, liveMode } from "@/lib/api/client";
export function SessionGate({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname() ?? "";
// The login screen itself is never gated (nor redirected).
const isLoginRoute = pathname === "/login";
// Mock mode never gates: render immediately, no getSession() call.
const [ready, setReady] = useState(!liveMode || isLoginRoute);
useEffect(() => {
if (!liveMode || isLoginRoute) return;
let cancelled = false;
api
.getSession()
.then((session) => {
if (cancelled) return;
if (session === null) {
router.replace("/login");
} else {
setReady(true);
}
})
.catch(() => {
// A guard that cannot ask the backend fails closed.
if (!cancelled) router.replace("/login");
});
return () => {
cancelled = true;
};
}, [router, isLoginRoute]);
if (!ready) {
return (
<div className="flex min-h-dvh items-center justify-center">
<p className="text-caption text-ink-faint">Loading…</p>
</div>
);
}
return <>{children}</>;
}
+425 -24
View File
@@ -1,26 +1,32 @@
"use client";
/**
* SettingsView (T-5.4, REQ-65) — the /settings screen.
* SettingsView (T-5.4, REQ-65; P2 T-2.9, REQ-72/73/74) — /settings.
*
* Five sections, all through the ApiClient boundary:
* - API keys: masked values (raw keys never reach the UI), endpoint
* labels, configured state
* - Storage: local directory reports are written to
* - Methodology defaults: default methodology + resolved 12-dial
* display (the REQ-04 reproducibility vocabulary)
* - Appearance: theme slot — preview-marked (paper palette is
* light-only today)
* - About & version: prototype notice + version
*
* Save persists through api.saveSettings; the mock round-trips it.
* Mock mode keeps the v0.1 five sections byte-for-byte (component
* tests pin them). Live/hosted mode adds the per-user surfaces on
* top, all through the ApiClient boundary:
* - Model credentials (REQ-73/74): resolved credential tier badge
* (user | server | keyless), masked model-key entry (password
* input → setModelKey; masked display "…1234"; the raw key is
* never echoed), honest disabled state when the key store is off
* (409 KeyStoreDisabledError → the reason is shown)
* - Model names (REQ-72): planner/writer inputs bound to
* getUserSettings/saveUserSettings
* - Usage (P-13): per-user cost counters (runs created/completed)
* - Storage: display-only in live mode (D-111) — the server manages
* the directory; no path picker
*/
import { useEffect, useState } from "react";
import {
api,
liveMode,
type ConfigStatus,
type Methodology,
type ModelKeyStatus,
type Settings,
type UserSettings,
} from "@/lib/api/client";
import { formatDialValue } from "@/lib/dials";
@@ -29,6 +35,19 @@ type SettingsState =
| { kind: "error"; message: string }
| { kind: "ready"; settings: Settings; methodologies: Methodology[] };
/** Live-mode per-user panel state (T-2.9) — keyed to the boundary. */
type UserSettingsState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; settings: UserSettings };
/** Resolved credential tier (REQ-74) — badge copy per tier. */
const TIER_COPY: Record<ConfigStatus["resolved_tier"], string> = {
user: "Your key",
server: "Server key",
keyless: "Keyless (localhost)",
};
export function SettingsView() {
const [state, setState] = useState<SettingsState>({ kind: "loading" });
const [storage, setStorage] = useState("");
@@ -37,6 +56,19 @@ export function SettingsView() {
"idle",
);
// ---- live-only per-user surface (T-2.9) --------------------------
const [userSettings, setUserSettings] = useState<UserSettingsState>({
kind: "loading",
});
const [configStatus, setConfigStatus] = useState<ConfigStatus | null>(null);
const [keyDraft, setKeyDraft] = useState("");
const [keyState, setKeyState] = useState<
{ kind: "idle" } | { kind: "saving" } | { kind: "saved" } | { kind: "error"; message: string }
>({ kind: "idle" });
const [modelsSaveState, setModelsSaveState] = useState<"idle" | "saving" | "saved" | "error">(
"idle",
);
useEffect(() => {
let cancelled = false;
(async () => {
@@ -64,6 +96,34 @@ export function SettingsView() {
};
}, []);
// Live mode only: per-user settings + credential/usage status.
useEffect(() => {
if (!liveMode) return;
let cancelled = false;
(async () => {
try {
const [user, status] = await Promise.all([
api.getUserSettings(),
api.getConfigStatus(),
]);
if (!cancelled) {
setUserSettings({ kind: "ready", settings: user });
setConfigStatus(status);
}
} catch (cause) {
if (!cancelled) {
setUserSettings({
kind: "error",
message: cause instanceof Error ? cause.message : "Account settings unavailable",
});
}
}
})();
return () => {
cancelled = true;
};
}, []);
if (state.kind === "loading") {
return <p className="mt-8 text-small text-ink-faint">Loading settings…</p>;
}
@@ -94,6 +154,49 @@ export function SettingsView() {
}
};
// ---- live per-user handlers ---------------------------------------
const userReady = userSettings.kind === "ready" ? userSettings.settings : null;
const saveModels = async (planner: string, writer: string) => {
setModelsSaveState("saving");
try {
const saved = await api.saveUserSettings({
planner_model: planner,
writer_model: writer,
});
if (userSettings.kind === "ready") {
setUserSettings({ kind: "ready", settings: saved });
}
setModelsSaveState("saved");
} catch {
setModelsSaveState("error");
}
};
const submitModelKey = async () => {
const raw = keyDraft.trim();
if (!raw) return;
setKeyState({ kind: "saving" });
try {
const status = await api.setModelKey(raw);
// The raw key NEVER survives this handler — clear the field and
// keep only the masked status the boundary returned.
setKeyDraft("");
setKeyState({ kind: "saved" });
if (configStatus) {
setConfigStatus({ ...configStatus, key_status: status, resolved_tier: "user" });
}
} catch (cause) {
setKeyDraft("");
setKeyState({
kind: "error",
message:
cause instanceof Error ? cause.message : "Could not store the key",
});
}
};
return (
<div>
<h1 className="text-display font-semibold text-ink">Settings</h1>
@@ -103,6 +206,48 @@ export function SettingsView() {
</p>
<div className="mt-block space-y-8">
{/* ---- Model credentials (live only, T-2.9) ----------------- */}
{liveMode && (
<ModelCredentialsSection
configStatus={configStatus}
keyDraft={keyDraft}
onKeyDraft={setKeyDraft}
keyState={keyState}
onSubmit={submitModelKey}
/>
)}
{/* ---- Model names (live only, T-2.9) ------------------------ */}
{liveMode && (
<section
aria-label="Model names"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Model names
</h2>
<p className="mt-1 text-caption text-ink-soft">
The models your runs use — saved to your account and applied
to every new run.
</p>
{userSettings.kind === "loading" && (
<p className="mt-4 text-caption text-ink-faint">Loading…</p>
)}
{userSettings.kind === "error" && (
<p role="alert" className="mt-4 text-caption text-danger">
{userSettings.message}
</p>
)}
{userReady && (
<ModelNamesForm
initial={userReady}
saveState={modelsSaveState}
onSave={saveModels}
/>
)}
</section>
)}
{/* ---- API keys --------------------------------------------- */}
<section
aria-label="API keys"
@@ -154,20 +299,62 @@ export function SettingsView() {
Local directory finished reports are written to — no upload
quotas, plain Markdown and JSON on disk.
</p>
<label className="mt-4 block">
<span className="text-small font-medium text-ink">Reports directory</span>
<input
type="text"
value={storage}
onChange={(event) => {
setStorage(event.target.value);
setSaveState("idle");
}}
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
/>
</label>
{liveMode && configStatus ? (
// D-111: server-managed, display-only in live mode — no picker.
<p
data-testid="storage-display"
className="mt-4 rounded-card border border-hairline bg-paper-sunken px-3 py-2 font-mono text-small text-ink"
>
{configStatus.storage_dir}
<span className="ml-2 text-caption text-ink-faint">
(server-managed — read only)
</span>
</p>
) : (
<label className="mt-4 block">
<span className="text-small font-medium text-ink">Reports directory</span>
<input
type="text"
value={storage}
onChange={(event) => {
setStorage(event.target.value);
setSaveState("idle");
}}
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
/>
</label>
)}
</section>
{/* ---- Usage (live only, P-13) -------------------------------- */}
{liveMode && configStatus && (
<section
aria-label="Usage"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Usage
</h2>
<p className="mt-1 text-caption text-ink-soft">
Your research activity on this server.
</p>
<dl className="mt-4 grid grid-cols-2 gap-4 rounded-card border border-hairline bg-paper px-4 py-4">
<div>
<dt className="text-caption text-ink-faint">Runs created</dt>
<dd className="text-body font-medium text-ink">
{configStatus.cost_counters.runs_created}
</dd>
</div>
<div>
<dt className="text-caption text-ink-faint">Runs completed</dt>
<dd className="text-body font-medium text-ink">
{configStatus.cost_counters.runs_completed}
</dd>
</div>
</dl>
</section>
)}
{/* ---- Methodology defaults ---------------------------------- */}
<section
aria-label="Methodology defaults"
@@ -276,6 +463,220 @@ export function SettingsView() {
);
}
/* -------------------------------------------------------------------- */
/* Live-mode per-user subcomponents (T-2.9) */
/* -------------------------------------------------------------------- */
/**
* Model credentials (REQ-73/74): the resolved credential tier badge,
* the masked key status, and the password-shaped key entry. The raw
* key lives in the input only until submit; the response is masked
* ("…1234"). A disabled key store (409) shows the honest reason.
*/
function ModelCredentialsSection({
configStatus,
keyDraft,
onKeyDraft,
keyState,
onSubmit,
}: {
configStatus: ConfigStatus | null;
keyDraft: string;
onKeyDraft: (next: string) => void;
keyState:
| { kind: "idle" }
| { kind: "saving" }
| { kind: "saved" }
| { kind: "error"; message: string };
onSubmit: () => void;
}) {
const tier = configStatus?.resolved_tier ?? "keyless";
const keyStatus: ModelKeyStatus | undefined = configStatus?.key_status;
const keyStoreEnabled = configStatus?.key_store === "enabled";
return (
<section
aria-label="Model credentials"
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Model credentials
</h2>
<p className="mt-1 text-caption text-ink-soft">
Your key is encrypted at rest and shown masked — never displayed raw.
</p>
{/* Resolved credential tier (REQ-74) — badge + honest copy. */}
<div className="mt-4 flex flex-wrap items-center gap-2">
<span className="text-small font-medium text-ink">Credential tier</span>
<span
data-testid="credential-tier"
className={`rounded-pill px-2.5 py-0.5 text-caption ${
tier === "user"
? "bg-success-wash text-success"
: tier === "server"
? "bg-accent-wash text-accent-ink"
: "bg-warn-wash text-warn"
}`}
>
{TIER_COPY[tier]}
</span>
<span className="text-caption text-ink-faint">
{tier === "user"
? "Your runs use your stored key."
: tier === "server"
? "Your runs use the server's key."
: "No key stored — runs fall back to keyless localhost."}
</span>
</div>
{/* Masked key status (REQ-73). */}
{keyStatus && (
<p
data-testid="key-status"
className="mt-3 rounded-card border border-hairline bg-paper px-4 py-3 text-caption text-ink-soft"
>
{keyStatus.configured ? (
<>
Model key stored —{" "}
<span data-testid="key-masked" className="font-medium text-ink">
{keyStatus.masked}
</span>
</>
) : (
"No model key stored."
)}
{keyStatus.reason && (
<span data-testid="key-reason" className="mt-1 block text-warn">
{keyStatus.reason}
</span>
)}
</p>
)}
{/* Key entry — disabled honestly when the store is off (409 class). */}
<label className="mt-4 block">
<span className="text-small font-medium text-ink">
{keyStatus?.configured ? "Replace model key" : "Add model key"}
</span>
<div className="mt-1 flex max-w-md gap-2">
<input
type="password"
// A disabled key store refuses with 409 — the input reflects
// the honest server state instead of pretending to accept.
disabled={!keyStoreEnabled}
autoComplete="off"
value={keyDraft}
onChange={(event) => onKeyDraft(event.target.value)}
placeholder="sk-…"
aria-label="Model key"
className="w-full rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink disabled:cursor-not-allowed disabled:opacity-40"
/>
<button
type="button"
onClick={onSubmit}
disabled={!keyStoreEnabled || keyDraft.trim() === "" || keyState.kind === "saving"}
className="shrink-0 rounded-pill bg-accent px-4 py-2 text-small font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
>
{keyState.kind === "saving" ? "Storing…" : "Store key"}
</button>
</div>
{!keyStoreEnabled && (
<p data-testid="key-store-disabled" className="mt-2 text-caption text-warn">
Key storage is disabled on this server —{" "}
{keyStatus?.reason ?? "the key store is not configured."}{" "}
Runs use the server tier or keyless localhost.
</p>
)}
{keyState.kind === "saved" && (
<p role="status" data-testid="key-saved" className="mt-2 text-caption text-success">
Key stored — shown masked above.
</p>
)}
{keyState.kind === "error" && (
<p role="alert" data-testid="key-error" className="mt-2 text-caption text-danger">
{keyState.message}
</p>
)}
</label>
</section>
);
}
/**
* Planner/writer model-name form (REQ-72) — bound to getUserSettings /
* saveUserSettings. Editing either field enables Save; a save
* round-trips and re-renders from the returned payload.
*/
function ModelNamesForm({
initial,
saveState,
onSave,
}: {
initial: UserSettings;
saveState: "idle" | "saving" | "saved" | "error";
onSave: (planner: string, writer: string) => void;
}) {
const [planner, setPlanner] = useState(initial.planner_model);
const [writer, setWriter] = useState(initial.writer_model);
const [touched, setTouched] = useState(false);
const dirty = touched && (planner !== initial.planner_model || writer !== initial.writer_model);
return (
<div className="mt-4 space-y-3">
<label className="block">
<span className="text-small font-medium text-ink">Planner model</span>
<input
type="text"
value={planner}
onChange={(event) => {
setPlanner(event.target.value);
setTouched(true);
}}
placeholder="gpt-oss:20b"
aria-label="Planner model"
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
/>
</label>
<label className="block">
<span className="text-small font-medium text-ink">Writer model</span>
<input
type="text"
value={writer}
onChange={(event) => {
setWriter(event.target.value);
setTouched(true);
}}
placeholder="gpt-oss:20b"
aria-label="Writer model"
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
/>
</label>
<div className="flex items-center gap-3">
<button
type="button"
disabled={!dirty || saveState === "saving"}
onClick={() => onSave(planner, writer)}
className="rounded-pill bg-accent px-5 py-2 text-small font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
>
{saveState === "saving" ? "Saving…" : "Save models"}
</button>
{saveState === "saved" && (
<p role="status" className="text-caption text-success">
Saved — applied to your next runs.
</p>
)}
{saveState === "error" && (
<p role="alert" className="text-caption text-danger">
Save failed — try again.
</p>
)}
</div>
</div>
);
}
/**
* The resolved default-dial display — labels from the DIALS registry.
* Previews the currently selected methodology (live), falling back to
@@ -0,0 +1,146 @@
/**
* UserMenu session display tests (T-1.10, REQ-69) — the avatar menu
* shows the REAL profile in live mode (display name + email from
* getSession) and keeps the fixture in mock mode, plus Logout wiring.
*
* The menu consults the boundary twice: getUserProfile() for the
* fixture display and getSession() for the live account. This suite
* stubs both through the ApiClient surface, driving live mode with a
* resolved session and mock mode with the real mock binding.
*/
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { UserMenu } from "@/components/user-menu";
import type { ApiClient, UserProfile } from "@/lib/api/client";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
usePathname: () => "/ask",
}));
// Boundary + mode injection: the suite swaps the module's `api` and
// `liveMode` exports per case (the menu reads both). The mock's `api`
// delegates to state.api at CALL time so re-assignment per test works.
const state = vi.hoisted(() => ({
api: {} as Record<string, unknown>,
liveMode: false,
}));
vi.mock("@/lib/api/client", async (importOriginal) => {
const actual = await importOriginal<
Promise<typeof import("@/lib/api/client")>
>();
return {
...actual,
api: new Proxy(
{},
{
get: (_target, prop) => {
const handler = state.api[prop as string];
return typeof handler === "function"
? (handler as (...args: unknown[]) => unknown).bind(state.api)
: handler;
},
},
),
// Getter export: the menu reads liveMode at render time, so the
// per-test assignment below must be visible.
get liveMode() {
return state.liveMode;
},
};
});
const SESSION_USER: UserProfile = {
user_id: "abcd2345",
email: "owner@example.org",
display_name: "Ada Owner",
role: "admin",
active: true,
created_at: "2026-09-29T12:00:00Z",
};
/** Boundary-shaped stub with an injectable live-mode flag + session. */
function makeApi(overrides: {
session: UserProfile | null;
logout: () => Promise<void>;
}): ApiClient {
return {
login: vi.fn(),
logout: vi.fn(overrides.logout),
getSession: vi.fn().mockResolvedValue(overrides.session),
listUsers: vi.fn(),
createUser: vi.fn(),
deactivateUser: vi.fn(),
getUserProfile: vi.fn().mockResolvedValue({
name: "Researcher",
initials: "RS",
tagline: "Local workspace — single user",
}),
} as unknown as ApiClient;
}
describe("UserMenu session display (T-1.10, REQ-69)", () => {
beforeEach(() => {
pushMock.mockClear();
});
afterEach(cleanup);
it("live mode shows the real session profile — display name + email", async () => {
state.api = makeApi({ session: SESSION_USER, logout: vi.fn() }) as unknown as Record<string, unknown>;
state.liveMode = true;
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
expect(await screen.findByText("Ada Owner")).toBeInTheDocument();
expect(screen.getByText("owner@example.org")).toBeInTheDocument();
// The avatar disc carries the real initials.
expect(screen.getByText("AO")).toBeInTheDocument();
// Never the fixture name in live mode with a resolved session.
expect(screen.queryByText("Researcher")).toBeNull();
});
it("mock mode keeps the fixture profile unchanged", async () => {
state.api = makeApi({ session: SESSION_USER, logout: vi.fn() }) as unknown as Record<string, unknown>;
state.liveMode = false;
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
expect(await screen.findByText("Researcher")).toBeInTheDocument();
expect(
screen.getByText("Local workspace — single user"),
).toBeInTheDocument();
});
it("logout calls the boundary then routes to /login", async () => {
const logout = vi.fn().mockResolvedValue(undefined);
const onDismiss = vi.fn();
state.api = makeApi({ session: SESSION_USER, logout }) as unknown as Record<string, unknown>;
state.liveMode = true;
const user = userEvent.setup();
render(<UserMenu onNavigate={() => {}} onDismiss={onDismiss} />);
await user.click(
await screen.findByRole("menuitem", { name: /Log out/i }),
);
await vi.waitFor(() => {
expect(logout).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith("/login");
expect(onDismiss).toHaveBeenCalled();
});
});
it("a failed logout still routes away (the cookie dies server-side)", async () => {
const logout = vi.fn().mockRejectedValue(new Error("network down"));
state.api = makeApi({ session: SESSION_USER, logout }) as unknown as Record<string, unknown>;
state.liveMode = true;
const user = userEvent.setup();
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
await user.click(
await screen.findByRole("menuitem", { name: /Log out/i }),
);
await vi.waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
});
+79 -14
View File
@@ -1,16 +1,19 @@
"use client";
/**
* UserMenu (T-5.2, REQ-66) — the avatar button's dropdown.
* UserMenu (T-5.2, REQ-66; T-1.10, REQ-69) — the avatar button's dropdown.
*
* Local single-user profile (no auth — single-user constraint), links to
* Settings and About (the settings page's about section). Escape and
* outside clicks dismiss. Rendered as a menu for assistive tech.
* Shows the profile: the real session user (display name + email via
* getSession) in live mode, the local fixture in mock mode — and a
* Logout action (revokes the session through the boundary, routes to
* /login). Escape and outside clicks dismiss. Rendered as a menu for
* assistive tech.
*/
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, type RefObject } from "react";
import { api, type UserProfile } from "@/lib/api/client";
import { api, liveMode, type DisplayProfile, type UserProfile } from "@/lib/api/client";
export function UserMenu({
onNavigate,
@@ -22,7 +25,10 @@ export function UserMenu({
/** The avatar button — clicks on it toggle, not dismiss-then-reopen. */
toggleRef?: RefObject<HTMLButtonElement | null>;
}) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const router = useRouter();
const [profile, setProfile] = useState<DisplayProfile | null>(null);
const [session, setSession] = useState<UserProfile | null>(null);
const [loggingOut, setLoggingOut] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -35,11 +41,52 @@ export function UserMenu({
.catch(() => {
/* fallback initials render below */
});
// The real session user drives the live-mode display (T-1.10);
// mock mode ignores it and keeps the fixture.
api
.getSession()
.then((user) => {
if (!cancelled) setSession(user);
})
.catch(() => {
/* menu falls back to the fixture display */
});
return () => {
cancelled = true;
};
}, []);
// Live mode with a resolved session shows the real account; mock
// mode (and any unresolved session) shows the fixture unchanged.
const showSession = liveMode && session !== null;
const name = showSession
? (session as UserProfile).display_name
: (profile?.name ?? "Researcher");
const detail = showSession
? (session as UserProfile).email
: (profile?.tagline ?? "Local workspace");
const initials = showSession
? initialsOf((session as UserProfile).display_name)
: (profile?.initials ?? "··");
// Logout: revoke the session through the boundary, then land on
// /login (live mode). Mock mode keeps the identical control — its
// logout is a local no-op that still routes, so the surface behaves
// the same in both bindings.
const handleLogout = () => {
if (loggingOut) return;
setLoggingOut(true);
api
.logout()
.catch(() => {
/* a failed revoke still leaves the app — the cookie dies server-side soonest */
})
.finally(() => {
onDismiss();
router.push("/login");
});
};
// Escape closes; outside clicks close. Focus stays in the header.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
@@ -72,15 +119,11 @@ export function UserMenu({
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-pill bg-accent text-small font-semibold text-paper-raised"
>
{profile?.initials ?? "··"}
{initials}
</span>
<div>
<p className="text-small font-medium text-ink">
{profile?.name ?? "Researcher"}
</p>
<p className="text-caption text-ink-faint">
{profile?.tagline ?? "Local workspace"}
</p>
<p className="text-small font-medium text-ink">{name}</p>
<p className="text-caption text-ink-faint">{detail}</p>
</div>
</div>
@@ -105,11 +148,33 @@ export function UserMenu({
About &amp; version
</Link>
</li>
<li>
<button
type="button"
role="menuitem"
onClick={handleLogout}
disabled={loggingOut}
className="mt-1 w-full rounded-card px-2 py-2 text-left text-small text-ink transition-colors hover:bg-paper-sunken disabled:cursor-not-allowed disabled:opacity-40"
>
{loggingOut ? "Signing out…" : "Log out"}
</button>
</li>
</ul>
<p className="mt-3 border-t border-hairline pt-3 text-caption text-ink-faint">
Local profile — accounts and sync land post-MVP.
{showSession
? `Signed in as ${session?.role} — sessions slide for 14 days.`
: "Local profile — accounts and sync land post-MVP."}
</p>
</div>
);
}
/** Two-letter initials from a display name ("Dev User" → "DU"). */
function initialsOf(displayName: string): string {
const words = displayName.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "··";
const first = words[0]?.[0] ?? "";
const second = words[1]?.[0] ?? words[0]?.[1] ?? "";
return `${first}${second}`.toUpperCase();
}
+127 -6
View File
@@ -4,15 +4,21 @@
* Components consume ONLY this interface via the `api` singleton. In
* v0.1 the singleton is bound to MockApiClient; in v0.2 phase 3 the
* binding swaps to the OpenAPI-generated client with no component
* rewrites. This is the ONLY module allowed to import MockApiClient.
* rewrites. Since v0.3 P3 (D-112) the live client is standalone —
* this boundary module selects the binding, and it is the ONLY module
* allowed to import MockApiClient.
*/
import type {
CitationLedger,
ConfigStatus,
ConnectionSlot,
CreateUserInput,
DialValues,
DisplayProfile,
InboxItem,
Methodology,
ModelKeyStatus,
ProjectGraph,
RefinementQuestion,
RefinedQuestion,
@@ -23,6 +29,7 @@ import type {
Settings,
SuggestedQuery,
TraceEvent,
UserSettings,
UserProfile,
} from "@/lib/types";
import { MockApiClient } from "@/lib/api/mock/mock-client";
@@ -32,12 +39,16 @@ export type { Report, ReportSection } from "@/lib/types";
export type {
Citation,
CitationLedger,
ConfigStatus,
ConnectionSlot,
CreateUserInput,
DialValues,
DisplayProfile,
GraphEdge,
GraphNode,
InboxItem,
Methodology,
ModelKeyStatus,
ProjectGraph,
RefinementQuestion,
RefinedQuestion,
@@ -54,8 +65,16 @@ export type {
SuggestedQuery,
Task,
TraceEvent,
UserSettings,
UserProfile,
} from "@/lib/types";
/**
* Typed key-store refusal (P2, REQ-73) — thrown by setModelKey when
* the backend answers 409 (key store disabled / no KEK). Exported as
* a VALUE through the boundary so consumers catch it the same way
* they catch AuthError.
*/
export { KeyStoreDisabledError } from "@/lib/types";
/** Input to createRun — the Ask + Choose screens' combined payload. */
export interface CreateRunInput {
@@ -153,8 +172,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[]>;
@@ -169,6 +191,44 @@ export interface ApiClient {
*/
saveSettings(update: Partial<Settings>): Promise<Settings>;
/* ---------------------------------------------------------------- */
/* v0.3 P2 per-user settings + model keys (T-2.9 — REQ-72/73/74). */
/* C3-18-adjacent extension: the exhaustive P2 additions. The */
/* v0.1 Settings surface stays untouched (mock-mode screens keep */
/* their behavior byte-for-byte); the per-user surface binds to */
/* GET/PUT /api/config/settings and the model-key routes. */
/* ---------------------------------------------------------------- */
/**
* The current user's per-user settings (REQ-72 — GET
* /api/config/settings). Fresh users get the server defaults
* (planner/writer "gpt-oss:20b", appearance "system").
*/
getUserSettings(): Promise<UserSettings>;
/**
* Persist a partial per-user settings update (REQ-72 — PUT
* /api/config/settings, CSRF header in live mode); returns the
* merged UserSettings.
*/
saveUserSettings(update: Partial<UserSettings>): Promise<UserSettings>;
/**
* Store the user's raw model key (REQ-73 — PUT
* /api/config/settings/model-key). The raw key is sent once and
* NEVER echoed: the resolved value is masked-only. A disabled
* server-side key store rejects with a typed KeyStoreDisabledError
* carrying the honest reason (409) — never a silent no-op.
*/
setModelKey(key: string): Promise<ModelKeyStatus>;
/**
* The current user's credential/usage status (REQ-74 — GET
* /api/config): resolved tier, key-store state, masked key status,
* adapters, storage dir (display-only), probe, cost counters.
*/
getConfigStatus(): Promise<ConfigStatus>;
/**
* Follow-up query suggestions for the New Research view —
* derived from unanswered open questions of past runs
@@ -191,14 +251,75 @@ 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
* switches to the OpenAPI-generated client in live mode — selected by
* NEXT_PUBLIC_RESEARCHX_API_MODE=live (mock stays the default). Live mode covers
* the core research loop only (G-002); other surfaces stay mock-backed.
* NEXT_PUBLIC_RESEARCHX_API_MODE=live (mock stays the default). Since
* v0.3 P3 (D-112) the live client is STANDALONE — every surface
* live-bound, zero delegation to the mock.
*/
class MockApiClientWithLifecycle extends MockApiClient implements RunLifecycle {
async pauseRun(id: string): Promise<ResearchRun> {
@@ -215,7 +336,7 @@ function resolveBinding(): ApiClient & RunLifecycle {
process.env.RESEARCHX_API_MODE ??
"mock";
if (mode === "live") {
return new GeneratedApiClient() as unknown as ApiClient & RunLifecycle;
return new GeneratedApiClient();
}
return new MockApiClientWithLifecycle();
}
+7 -2
View File
@@ -161,11 +161,16 @@ describe("E2E happy path — real stack, defaults only (REQ-17)", () => {
}
}, 55_000);
});
// The engine's terminal signal arrived on the stream: onDone only
// fires after the run_done event (the stream closes on it).
expect(done).toBe(true);
expect(events).toContain("status");
const final = await client.getRun(run.id);
expect(final.status).toBe("done");
// P-6 (registry pruning, T-1.8): the terminal run's registry entry
// is dropped once its last SSE subscriber leaves. A run GET after
// the stream 404s — documented acceptable; the trace persists on
// disk (the events-404-after-prune class, pinned here end to end).
await expect(client.getRun(run.id)).rejects.toThrow(/failed to load run/);
expect(userActions).toBeLessThanOrEqual(3);
}, 90_000);
File diff suppressed because it is too large Load Diff
+105 -29
View File
@@ -9,15 +9,35 @@ import { describe, expect, it } from "vitest";
import { GeneratedApiClient } from "@/lib/api/live-client";
import type { BackendRun } from "@/lib/api/test-helpers";
type RouteHandler = (url: string, init?: RequestInit) => { status: number; body: unknown };
type RouteHandler = (
url: string,
init?: RequestInit,
body?: string,
) => { status: number; body: unknown };
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
const request = input instanceof Request ? input : undefined;
const url = request ? request.url : String(input);
// openapi-fetch serializes the body onto the Request — surface it
// (plus credentials/headers) back to the route handler so tests
// can assert the CSRF header and cookie discipline per call.
let body: string | undefined;
let sentInit: RequestInit | undefined = init;
if (request) {
const cloned = request.clone();
body = cloned.body ? await cloned.text() : undefined;
sentInit = {
...init,
method: request.method,
credentials: request.credentials,
headers: request.headers,
};
}
for (const [prefix, handler] of Object.entries(routes)) {
if (url.startsWith(prefix)) {
const { status, body } = handler(url, init);
return new Response(JSON.stringify(body), {
const { status, body: responseBody } = handler(url, sentInit, body);
return new Response(JSON.stringify(responseBody), {
status,
headers: { "Content-Type": "application/json" },
});
@@ -27,6 +47,26 @@ function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fet
}) as typeof globalThis.fetch;
}
/** Headers actually sent on a call (CSRF + cookie assertions). */
function headersOf(init?: RequestInit): Record<string, string> {
const raw = init?.headers;
const record: Record<string, string> = {};
if (
typeof raw === "object" &&
raw !== null &&
typeof (raw as Headers).forEach === "function"
) {
(raw as Headers).forEach((v, k) => {
record[k] = v;
});
} else if (Array.isArray(raw)) {
for (const [k, v] of raw) record[String(k)] = String(v);
} else if (raw) {
Object.assign(record, raw);
}
return record;
}
const backendRun: BackendRun = {
id: "abcd2345",
status: "awaiting_approval",
@@ -54,7 +94,20 @@ describe("GeneratedApiClient (live binding)", () => {
cost_estimate: "~5k tokens (estimate)",
why_recommended: "Fastest path to a confident cited answer.",
output_shape: "memo",
dials: { depth: 1, breadth: 1 },
dials: {
source_universe: "web",
search_routing: "single",
query_strategy: "decomposed",
breadth: 1,
depth: 1,
quality_gates: "standard",
citation_rigor: "per-claim",
verification_passes: 1,
synthesis_style: "memo",
output_structure: "brief",
memory_scope: "none",
checkpoints: false,
},
},
],
}),
@@ -65,18 +118,41 @@ describe("GeneratedApiClient (live binding)", () => {
expect(cards[0]?.slug).toBe("rapid-answer");
expect(cards[0]?.etaMinutes).toBe(2);
expect(cards[0]?.costEstimate).toContain("estimate");
expect(cards[0]?.defaultDials).toEqual({ depth: 1, breadth: 1 });
// Snake_case dials map into the full 12-key frontend DialValues
// shape (T-3.8): the Advanced drawer reads every key.
expect(cards[0]?.defaultDials).toEqual({
sourceUniverse: "web",
searchRouting: "single",
queryStrategy: "decomposed",
breadth: 1,
depth: 1,
qualityGates: "standard",
citationRigor: "per-claim",
verificationPasses: 1,
synthesisStyle: "memo",
outputStructure: "brief",
memoryScope: "none",
checkpoints: "plan-only",
});
});
it("creates runs through the backend contract", async () => {
const routes = {
"http://127.0.0.1:8000/api/runs": (url: string, init?: RequestInit) => {
const method =
init?.method ?? (url.endsWith("/api/runs") ? "POST" : undefined);
return {
status: 201,
body: method === "POST" ? backendRun : { detail: "wrong method" },
};
"http://127.0.0.1:8000/api/runs": (
_url: string,
init?: RequestInit,
body?: string,
) => {
expect(init?.method).toBe("POST");
// T-3.8 — the P1-verify flag stays closed: EVERY core-loop
// mutation rides credentials + the CSRF header (C3-05).
expect(init?.credentials).toBe("include");
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(JSON.parse(body ?? "null")).toMatchObject({
question: "How do engines run?",
methodology: "rapid-answer",
});
return { status: 201, body: backendRun };
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
@@ -91,29 +167,29 @@ describe("GeneratedApiClient (live binding)", () => {
});
it("approves, cancels, pauses, resumes via lifecycle routes", async () => {
// The discipline holds on EVERY lifecycle mutation, not just the
// create — the wrapper is the single injection point (T-3.8).
const seen: Array<Record<string, unknown>> = [];
const capture =
(status: string) =>
(_url: string, init?: RequestInit): { status: number; body: unknown } => {
expect(init?.credentials).toBe("include");
expect(headersOf(init)["x-researchx-request"]).toBe("1");
seen.push({ method: init?.method, status });
return { status: 200, body: { ...backendRun, status } };
};
const routes = {
"http://127.0.0.1:8000/api/runs/abcd2345/approve": () => ({
status: 200,
body: { ...backendRun, status: "searching" },
}),
"http://127.0.0.1:8000/api/runs/abcd2345/cancel": () => ({
status: 200,
body: { ...backendRun, status: "cancelled" },
}),
"http://127.0.0.1:8000/api/runs/abcd2345/pause": () => ({
status: 200,
body: { ...backendRun, status: "searching" },
}),
"http://127.0.0.1:8000/api/runs/abcd2345/resume": () => ({
status: 200,
body: { ...backendRun, status: "verifying" },
}),
"http://127.0.0.1:8000/api/runs/abcd2345/approve": capture("searching"),
"http://127.0.0.1:8000/api/runs/abcd2345/cancel": capture("cancelled"),
"http://127.0.0.1:8000/api/runs/abcd2345/pause": capture("searching"),
"http://127.0.0.1:8000/api/runs/abcd2345/resume": capture("verifying"),
};
const client = new GeneratedApiClient(stubRoutes(routes));
expect((await client.approvePlan("abcd2345")).status).toBe("searching");
expect((await client.cancelRun("abcd2345")).status).toBe("cancelled");
expect((await client.pauseRun("abcd2345")).status).toBe("searching");
expect((await client.resumeRun("abcd2345")).status).toBe("verifying");
expect(seen.map((s) => s.method)).toEqual(["POST", "POST", "POST", "POST"]);
});
it("maps the ledger to frontend citations with health + tier", async () => {
File diff suppressed because it is too large Load Diff
+564
View File
@@ -0,0 +1,564 @@
/**
* Live-surface boundary tests (T-3.9, REQ-76..81 — the surface swaps).
*
* Every previously-delegated surface, contract-tested against a
* stubbed global fetch (no network, no keys): real routes, cookie
* discipline on every call, CSRF header on every mutation (C3-05),
* payloads mapped through the SAME frontend types the components
* consume (zero rewrites proven — REQ-54).
*/
import { describe, expect, it } from "vitest";
import { GeneratedApiClient } from "@/lib/api/live-client";
import { MockApiClient } from "@/lib/api/mock/mock-client";
type RouteHandler = (
url: string,
init?: RequestInit,
body?: string,
) => { status: number; body: unknown };
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : undefined;
const url = request ? request.url : String(input);
// openapi-fetch serializes the body onto the Request — surface it
// (plus credentials/headers) back to the route handler.
let body: string | undefined;
let sentInit: RequestInit | undefined = init;
if (request) {
const cloned = request.clone();
body = cloned.body ? await cloned.text() : undefined;
sentInit = {
...init,
method: request.method,
credentials: request.credentials,
headers: request.headers,
};
}
for (const [prefix, handler] of Object.entries(routes)) {
if (url.startsWith(prefix)) {
const { status, body: responseBody } = handler(url, sentInit, body);
return new Response(JSON.stringify(responseBody), {
status,
headers: { "Content-Type": "application/json" },
});
}
}
return new Response(JSON.stringify({ detail: "not found" }), { status: 404 });
}) as typeof globalThis.fetch;
}
/** Headers actually sent on a call (CSRF + cookie assertions). */
function headersOf(init?: RequestInit): Record<string, string> {
const raw = init?.headers;
const record: Record<string, string> = {};
if (
typeof raw === "object" &&
raw !== null &&
typeof (raw as Headers).forEach === "function"
) {
(raw as Headers).forEach((v, k) => {
record[k] = v;
});
} else if (Array.isArray(raw)) {
for (const [k, v] of raw) record[String(k)] = String(v);
} else if (raw) {
Object.assign(record, raw);
}
return record;
}
const BASE = "http://127.0.0.1:8000";
/* ------------------------------------------------------------------ */
/* Read-model payloads (verbatim shapes from the live app) */
/* ------------------------------------------------------------------ */
const LIBRARY_PAYLOAD = {
folders: [
{
id: "deep-report-battery",
name: "Deep Report: Battery",
description: "Auto-filed from deep report battery research.",
},
],
reports: [
{
id: "rep-runaaaa1",
runId: "runaaaa1",
title: "Battery Advances",
question: "What about battery research?",
methodologySlug: "deep-report",
category: "deep-report-battery",
generatedAt: "2026-09-20T10:05:00Z",
citationCount: 1,
},
],
};
const CONNECTIONS_PAYLOAD = [
{
fromReportId: "rep-runaaaa1",
toReportId: "rep-runbbbb1",
kind: "shared-sources",
label: "2 shared sources",
},
{
fromReportId: "rep-runaaaa1",
toReportId: "rep-runcccc1",
kind: "related-topic",
label: "Adjacent topic",
},
];
const GRAPH_PAYLOAD = {
nodes: [
{
id: "runaaaa1",
topic: "Battery Advances",
linkedSources: ["https://example.org/x"],
findings: ["Battery Advances"],
},
],
edges: [{ fromId: "runaaaa1", toId: "runbbbb1", sharedSourceCount: 2 }],
};
const TASKS_PAYLOAD = [
{
id: "runaaaa1#0",
question: "What is the research question?",
sourceRunId: "runaaaa1",
status: "open",
},
{
id: "runbbbb1#0",
question: "An answered question",
sourceRunId: "runbbbb1",
status: "done",
},
];
const SUGGESTIONS_PAYLOAD = [
{
id: "sug-runcccc1#0",
question: "What is the research question?",
sourceRunId: "runcccc1",
sourceReportTitle: "Deep Report — an in-flight run",
},
];
const PROFILE_PAYLOAD = {
user_id: "abcd2345",
display_name: "Ada Owner",
email: "owner@example.org",
};
const CONFIG_PAYLOAD = {
resolved_tier: "user",
key_store: "enabled",
key_status: {
configured: true,
masked: "…7f2a",
disabled: false,
reason: null,
},
model_adapter: "OllamaModelAdapter",
search_adapter: "TavilySearchAdapter",
storage_dir: "/var/lib/researchx/runs",
probe: null,
cost_counters: { runs_created: 4, runs_completed: 3 },
};
/** One stub serving every read-model surface at once. */
function surfaceRoutes(): Record<string, RouteHandler> {
return {
[`${BASE}/api/library/connections`]: () => ({
status: 200,
body: CONNECTIONS_PAYLOAD,
}),
[`${BASE}/api/library`]: (_url: string, init?: RequestInit) => {
expect(init?.credentials).toBe("include");
return { status: 200, body: LIBRARY_PAYLOAD };
},
[`${BASE}/api/graph`]: () => ({ status: 200, body: GRAPH_PAYLOAD }),
[`${BASE}/api/tasks`]: () => ({ status: 200, body: TASKS_PAYLOAD }),
[`${BASE}/api/suggestions`]: () => ({
status: 200,
body: SUGGESTIONS_PAYLOAD,
}),
[`${BASE}/api/profile`]: () => ({ status: 200, body: PROFILE_PAYLOAD }),
[`${BASE}/api/config`]: () => ({ status: 200, body: CONFIG_PAYLOAD }),
};
}
describe("GeneratedApiClient live surfaces (T-3.9)", () => {
it("getReportLibrary maps the camelCase read-model payload 1:1", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const library = await client.getReportLibrary();
expect(library.folders).toHaveLength(1);
expect(library.folders[0]?.id).toBe("deep-report-battery");
expect(library.reports).toHaveLength(1);
expect(library.reports[0]).toEqual({
id: "rep-runaaaa1",
runId: "runaaaa1",
title: "Battery Advances",
question: "What about battery research?",
methodologySlug: "deep-report",
category: "deep-report-battery",
generatedAt: "2026-09-20T10:05:00Z",
citationCount: 1,
});
});
it("getReportConnections maps kind + label edges", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const edges = await client.getReportConnections();
expect(edges).toEqual([
{
fromReportId: "rep-runaaaa1",
toReportId: "rep-runbbbb1",
kind: "shared-sources",
label: "2 shared sources",
},
{
fromReportId: "rep-runaaaa1",
toReportId: "rep-runcccc1",
kind: "related-topic",
label: "Adjacent topic",
},
]);
});
it("getProjectGraph maps nodes + edges", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const graph = await client.getProjectGraph();
expect(graph.nodes[0]?.topic).toBe("Battery Advances");
expect(graph.nodes[0]?.linkedSources).toEqual(["https://example.org/x"]);
expect(graph.edges[0]?.sharedSourceCount).toBe(2);
});
it("getTaskInbox maps derived statuses (G-109: open|done only)", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const inbox = await client.getTaskInbox();
expect(inbox).toEqual([
{
id: "runaaaa1#0",
question: "What is the research question?",
sourceRunId: "runaaaa1",
status: "open",
},
{
id: "runbbbb1#0",
question: "An answered question",
sourceRunId: "runbbbb1",
status: "done",
},
]);
});
it("getSuggestedQueries maps open items with source titles; honest empty", async () => {
const withItems = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const suggestions = await withItems.getSuggestedQueries();
expect(suggestions[0]?.sourceReportTitle).toBe("Deep Report — an in-flight run");
expect(suggestions[0]?.id).toBe("sug-runcccc1#0");
// A fresh user gets an honest empty array — never synthetic content.
const empty = new GeneratedApiClient(
stubRoutes({
[`${BASE}/api/suggestions`]: () => ({ status: 200, body: [] }),
}),
);
expect(await empty.getSuggestedQueries()).toEqual([]);
});
it("getUserProfile maps GET /api/profile into the DisplayProfile", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const profile = await client.getUserProfile();
expect(profile.name).toBe("Ada Owner");
expect(profile.initials).toBe("AO");
expect(profile.tagline).toBe("owner@example.org");
});
it("getConnectionStatus maps GET /api/config through the five slots (P2-verify closure)", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const slots = await client.getConnectionStatus();
expect(slots.map((s) => s.slot)).toEqual([
"model",
"search",
"storage",
"memory",
"secrets",
]);
const model = slots[0];
expect(model?.status).toBe("connected"); // tier "user" — not keyless
expect(model?.resolvedTier).toBe("user");
expect(model?.keyStore).toBe("enabled");
expect(model?.keyStatus?.masked).toBe("…7f2a");
expect(model?.detail).toContain("OllamaModelAdapter");
// Storage is server-managed, display-only (D-111).
expect(slots[2]?.detail).toContain("/var/lib/researchx/runs");
});
it("getSettings composes the v0.1 shape live from config + catalog", async () => {
const client = new GeneratedApiClient(
stubRoutes({
...surfaceRoutes(),
[`${BASE}/api/methodologies`]: () => ({
status: 200,
body: [
{
slug: "rapid-answer",
name: "Rapid Answer",
description: "One pass, one memo.",
eta_minutes: 2,
cost_estimate: "~5k tokens (estimate)",
why_recommended: "Fastest.",
output_shape: "memo",
dials: {
source_universe: "web",
search_routing: "single",
query_strategy: "decomposed",
breadth: 1,
depth: 1,
quality_gates: "standard",
citation_rigor: "per-claim",
verification_passes: 1,
synthesis_style: "memo",
output_structure: "brief",
memory_scope: "none",
checkpoints: false,
},
},
{
slug: "deep-report",
name: "Deep Report",
description: "Full depth.",
eta_minutes: 12,
cost_estimate: "~25k tokens (estimate)",
why_recommended: "Thorough.",
output_shape: "report",
dials: {
source_universe: "web+academic",
search_routing: "adaptive",
query_strategy: "decomposed",
breadth: 2,
depth: 3,
quality_gates: "strict",
citation_rigor: "per-claim",
verification_passes: 2,
synthesis_style: "executive",
output_structure: "report",
memory_scope: "none",
checkpoints: true,
},
},
],
}),
}),
);
const settings = await client.getSettings();
// The recommended card is the first in catalog order here — deep-report
// drives the resolved default (index 0 = recommended per the mapping).
expect(settings.defaultMethodologySlug).toBe("rapid-answer");
expect(settings.storageDirectory).toBe("/var/lib/researchx/runs");
// Masked-only key status — never the raw key.
expect(settings.apiKeys[0]?.configured).toBe(true);
expect(settings.apiKeys[0]?.maskedValue).toBe("…7f2a");
expect(settings.theme).toBe("paper");
});
it("saveSettings answers with the authoritative server read (D-111)", async () => {
const client = new GeneratedApiClient(
stubRoutes({
...surfaceRoutes(),
[`${BASE}/api/methodologies`]: () => ({
status: 200,
body: [
{
slug: "rapid-answer",
name: "Rapid Answer",
description: "One pass, one memo.",
eta_minutes: 2,
cost_estimate: "~5k tokens (estimate)",
why_recommended: "Fastest.",
output_shape: "memo",
dials: {
source_universe: "web",
search_routing: "single",
query_strategy: "decomposed",
breadth: 1,
depth: 1,
quality_gates: "standard",
citation_rigor: "per-claim",
verification_passes: 1,
synthesis_style: "memo",
output_structure: "brief",
memory_scope: "none",
checkpoints: false,
},
},
],
}),
}),
);
// The server owns every v0.1 field live — a save is a re-read,
// never a client-side pretend-persist.
const saved = await client.saveSettings({ storageDirectory: "~/elsewhere" });
expect(saved.storageDirectory).toBe("/var/lib/researchx/runs");
});
});
/* ------------------------------------------------------------------ */
/* Refine — real POSTs, honest 503 (T-3.6/T-3.9) */
/* ------------------------------------------------------------------ */
describe("GeneratedApiClient refine (live binding, REQ-80)", () => {
it("getRefinementQuestions POSTs the draft with CSRF + credentials", async () => {
const client = new GeneratedApiClient(
stubRoutes({
[`${BASE}/api/refine/questions`]: (
_url: string,
init?: RequestInit,
body?: string,
) => {
expect(init?.method).toBe("POST");
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
const parsed = JSON.parse(body ?? "null") as { draft: string };
expect(parsed.draft).toBe("How do batteries work?");
return { status: 200, body: { questions: ["Which scope?", "What decision?"] } };
},
}),
);
const questions = await client.getRefinementQuestions("How do batteries work?");
expect(questions).toHaveLength(2);
expect(questions[0]?.prompt).toBe("Which scope?");
expect(questions[0]?.optional).toBe(false); // the first question is required
expect(questions[1]?.optional).toBe(true); // the rest are skippable
});
it("getRefinementQuestions 503 → honest error, never a hang, never synthetic", async () => {
const client = new GeneratedApiClient(
stubRoutes({
[`${BASE}/api/refine/questions`]: () => ({
status: 503,
body: { detail: "no local endpoint and no key — tier unavailable" },
}),
}),
);
await expect(client.getRefinementQuestions("draft")).rejects.toThrow(
/refine is unavailable/i,
);
});
it("composeRefinedQuestion POSTs ordered answers with CSRF + credentials", async () => {
const client = new GeneratedApiClient(
stubRoutes({
[`${BASE}/api/refine/compose`]: (
_url: string,
init?: RequestInit,
body?: string,
) => {
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
const parsed = JSON.parse(body ?? "null") as {
draft: string;
answers: string[];
};
// Answers travel in exchange order, trimmed.
expect(parsed.draft).toBe("How do batteries work?");
expect(parsed.answers).toEqual(["automotive", "supplier choice", ""]);
return {
status: 200,
body: {
question: "How do batteries work? — automotive focus",
context: "Feeds a supplier choice.",
},
};
},
}),
);
const refined = await client.composeRefinedQuestion("How do batteries work?", {
"q-1": "automotive",
"q-2": "supplier choice",
"q-3": "",
});
expect(refined.question).toBe("How do batteries work? — automotive focus");
expect(refined.context).toBe("Feeds a supplier choice.");
});
it("composeRefinedQuestion 503 → honest error", async () => {
const client = new GeneratedApiClient(
stubRoutes({
[`${BASE}/api/refine/compose`]: () => ({
status: 503,
body: { detail: "planner unreachable" },
}),
}),
);
await expect(
client.composeRefinedQuestion("draft", { "q-1": "answer" }),
).rejects.toThrow(/refine is unavailable/i);
});
});
/* ------------------------------------------------------------------ */
/* D-112: the delegation is DEAD — structural proof */
/* ------------------------------------------------------------------ */
describe("GeneratedApiClient standalone (D-112)", () => {
it("has no MockApiClient in its prototype chain — the delegation is gone", () => {
const client = new GeneratedApiClient();
expect(client).not.toBeInstanceOf(MockApiClient);
let proto = Object.getPrototypeOf(client);
while (proto !== null) {
expect(proto.constructor.name).not.toBe("MockApiClient");
proto = Object.getPrototypeOf(proto);
}
});
it("implements every ApiClient surface as its own methods", () => {
const client = new GeneratedApiClient();
const required = [
"getMethodologies",
"createRun",
"getRun",
"approvePlan",
"cancelRun",
"pauseRun",
"resumeRun",
"getRunEvents",
"getReport",
"getLedger",
"exportReport",
"getProjectGraph",
"getTaskInbox",
"getReportLibrary",
"getReportConnections",
"getUserProfile",
"getConnectionStatus",
"getSettings",
"saveSettings",
"getUserSettings",
"saveUserSettings",
"setModelKey",
"getConfigStatus",
"getSuggestedQueries",
"getRefinementQuestions",
"composeRefinedQuestion",
"login",
"logout",
"getSession",
"listUsers",
"createUser",
"deactivateUser",
] as const;
for (const method of required) {
expect(
typeof (client as unknown as Record<string, unknown>)[method],
).toBe("function");
}
});
});
+162 -1
View File
@@ -7,6 +7,7 @@
*/
import type { ApiClient, CreateRunInput } from "@/lib/api/client";
import { AuthError } from "@/lib/api/client";
import {
buildSuggestedQueries,
composeRefinedQuestion,
@@ -22,6 +23,7 @@ import {
reportConnectionsFixture,
reportLibraryFixture,
settingsFixture,
userSettingsFixture,
userProfileFixture,
} from "@/lib/api/mock/shell-fixtures";
import {
@@ -32,9 +34,13 @@ import {
} from "@/lib/api/mock/scripted-run";
import type {
CitationLedger,
ConfigStatus,
ConnectionSlot,
CreateUserInput,
DisplayProfile,
InboxItem,
Methodology,
ModelKeyStatus,
ProjectGraph,
RefinementQuestion,
RefinedQuestion,
@@ -45,6 +51,7 @@ import type {
Settings,
SuggestedQuery,
TraceEvent,
UserSettings,
UserProfile,
} from "@/lib/types";
@@ -274,7 +281,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);
}
@@ -308,6 +315,74 @@ export class MockApiClient implements ApiClient {
/** Session-persisted settings (mock-local). */
#settings: Settings = structuredClone(settingsFixture);
/* ---------------------------------------------------------------- */
/* v0.3 P2 per-user settings + model keys (T-2.9, REQ-72..74). */
/* The mock is an honest zero-backend fixture: it mirrors the */
/* backend's DEFAULT-disabled key store (no KEK in mock mode) so */
/* the UI's degraded states are exercised, not hidden. */
/* ---------------------------------------------------------------- */
/** Per-user settings — starts from the backend's fresh-user defaults. */
#userSettings: UserSettings = structuredClone(userSettingsFixture);
/** In-memory masked key state — the raw key is NEVER stored. */
#modelKey: ModelKeyStatus = {
configured: false,
masked: null,
disabled: false,
reason: null,
};
/** Per-user settings (REQ-72) — session-local, defaults for fresh users. */
async getUserSettings(): Promise<UserSettings> {
return structuredClone(this.#userSettings);
}
/** Persist a partial per-user settings update (REQ-72) — in-memory. */
async saveUserSettings(update: Partial<UserSettings>): Promise<UserSettings> {
const next: UserSettings = {
...this.#userSettings,
...structuredClone(update),
};
this.#userSettings = next;
return structuredClone(next);
}
/**
* Store the raw model key (REQ-73): the mock keeps ONLY the masked
* tail in memory — the raw value is accepted, masked, and dropped.
* Mirrors the backend's mask shape ("…" + last 4).
*/
async setModelKey(key: string): Promise<ModelKeyStatus> {
const masked = `…${key.slice(-4)}`;
this.#modelKey = {
configured: true,
masked: key.length >= 4 ? masked : "…",
disabled: false,
reason: null,
};
return structuredClone(this.#modelKey);
}
/**
* Honest mock config status (REQ-74): the mock has no per-user key
* store (zero-backend fixture), so resolved_tier degrades to
* "keyless" — the documented keyless-localhost tier — and key_store
* reports "disabled" exactly like a backend without a KEK.
*/
async getConfigStatus(): Promise<ConfigStatus> {
return {
resolved_tier: "keyless",
key_store: "disabled",
key_status: structuredClone(this.#modelKey),
model_adapter: "FakeModelAdapter",
search_adapter: "FakeSearchAdapter",
storage_dir: settingsFixture.storageDirectory,
probe: null,
cost_counters: { runs_created: 0, runs_completed: 0 },
};
}
/**
* Follow-up suggestions from unanswered open questions (REQ-67).
* Derived live from the inbox fixtures — a completed item drops
@@ -334,6 +409,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({
+34 -3
View File
@@ -5,13 +5,19 @@
* masked API-key settings, adapter-slot connection status, and the local
* single-user profile. Clearly example content, consistent with the
* battery-research domain used across the prototype fixtures.
*
* v0.3 P2 (T-2.9): adds the per-user settings fixture (the backend's
* fresh-user defaults — planner/writer "gpt-oss:20b", appearance
* "system") and the honest keyless/degraded credential fields on the
* model connection slot (resolvedTier "keyless", keyStore "disabled" —
* the mock has no key store, exactly like a backend without a KEK).
*/
import type { Settings } from "@/lib/types";
import type { Settings, UserSettings } from "@/lib/types";
import type {
DisplayProfile,
ReportConnection,
ReportLibrary,
UserProfile,
} from "@/lib/types";
/* ------------------------------------------------------------------ */
@@ -157,6 +163,19 @@ export const settingsFixture: Settings = {
theme: "paper",
};
/**
* Per-user settings fixture (P2, REQ-72) — mirrors the backend's
* fresh-user defaults exactly (auth/settings.py: planner/writer
* "gpt-oss:20b", appearance "system"). The mock has no per-user
* persistence across sessions; this is the starting payload.
*/
export const userSettingsFixture: UserSettings = {
planner_model: "gpt-oss:20b",
writer_model: "gpt-oss:20b",
methodology_defaults: {},
appearance: "system",
};
/* ------------------------------------------------------------------ */
/* Connection status (REQ-65 — adapter-slot health) */
/* ------------------------------------------------------------------ */
@@ -169,6 +188,18 @@ export const connectionStatusFixture: ConnectionSlot[] = [
label: "Model",
status: "connected",
detail: "Ollama cloud — key detected",
// P2 (REQ-74) honest mock: the zero-backend fixture has no
// per-user key store and no server key, so the tier degrades to
// keyless localhost — the documented keyless path, not a lie.
resolvedTier: "keyless",
keyStore: "disabled",
keyStatus: {
configured: false,
masked: null,
disabled: true,
reason:
"mock fixture: no key store (zero-backend mode) — the credential chain stays keyless",
},
},
{
slot: "search",
@@ -200,7 +231,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);
});
});
@@ -0,0 +1,313 @@
/**
* Per-user settings + model-key boundary tests (T-2.9, REQ-72/73/74).
*
* The v0.3 P2 ApiClient additions — getUserSettings / saveUserSettings /
* setModelKey / getConfigStatus — contract-tested against BOTH bindings:
* - the mock binding: honest zero-backend fixture (fresh-user defaults,
* in-memory masked key state, keyless tier, key store "disabled"), and
* - the live binding against a stubbed global fetch (no network, no
* keys): real routes, CSRF header on every mutation (C3-05),
* credentials:"include" cookie discipline, masked-only key echo,
* and the honest 409 KeyStoreDisabledError refusal.
*/
import { describe, expect, it } from "vitest";
// client.ts first (runtime import — it binds MockApiClient before the
// live client extends it); KeyStoreDisabledError is consumed through
// the boundary like AuthError in the session tests.
import { KeyStoreDisabledError, type ApiClient } from "@/lib/api/client";
import { MockApiClient } from "@/lib/api/mock/mock-client";
import { GeneratedApiClient } from "@/lib/api/live-client";
/* ------------------------------------------------------------------ */
/* Mock binding — standalone per-user fixture */
/* ------------------------------------------------------------------ */
describe("MockApiClient per-user settings surface (T-2.9)", () => {
it("getUserSettings returns the fresh-user defaults (planner/writer gpt-oss:20b)", async () => {
const client: ApiClient = new MockApiClient();
const settings = await client.getUserSettings();
expect(settings.planner_model).toBe("gpt-oss:20b");
expect(settings.writer_model).toBe("gpt-oss:20b");
expect(settings.methodology_defaults).toEqual({});
expect(settings.appearance).toBe("system");
});
it("saveUserSettings updates in-memory state and round-trips", async () => {
const client: ApiClient = new MockApiClient();
const saved = await client.saveUserSettings({
planner_model: "gpt-oss:120b",
writer_model: "llama3.1:70b",
});
expect(saved.planner_model).toBe("gpt-oss:120b");
expect(saved.writer_model).toBe("llama3.1:70b");
// Fresh read sees the persisted payload.
const reread = await client.getUserSettings();
expect(reread.planner_model).toBe("gpt-oss:120b");
expect(reread.writer_model).toBe("llama3.1:70b");
});
it("saveUserSettings keeps unset fields (partial update semantics)", async () => {
const client: ApiClient = new MockApiClient();
const saved = await client.saveUserSettings({ planner_model: "mistral:small" });
// writer_model was not in the update — the default survives.
expect(saved.planner_model).toBe("mistral:small");
expect(saved.writer_model).toBe("gpt-oss:20b");
});
it("setModelKey stores ONLY the masked tail in memory — never the raw key", async () => {
const client: ApiClient = new MockApiClient();
const status = await client.setModelKey("sk-live-raw-secret-7f2a");
expect(status.configured).toBe(true);
expect(status.masked).toBe("…7f2a");
expect(status.disabled).toBe(false);
// getConfigStatus reflects the stored masked state — the raw key
// never appears in ANY response the boundary produces.
const config = await client.getConfigStatus();
expect(config.key_status.configured).toBe(true);
expect(config.key_status.masked).toBe("…7f2a");
expect(JSON.stringify(config)).not.toContain("sk-live-raw-secret-7f2a");
});
it("getConfigStatus is honest: keyless tier, disabled key store", async () => {
const client: ApiClient = new MockApiClient();
const config = await client.getConfigStatus();
// The mock has no key store and no server key — the honest fixture.
expect(config.resolved_tier).toBe("keyless");
expect(config.key_store).toBe("disabled");
expect(config.cost_counters).toEqual({ runs_created: 0, runs_completed: 0 });
expect(config.storage_dir).toBeTruthy();
});
it("connection status carries the per-user tier extension on the model slot", async () => {
const client: ApiClient = new MockApiClient();
const slots = await client.getConnectionStatus();
const model = slots.find((s) => s.slot === "model");
expect(model?.resolvedTier).toBe("keyless");
expect(model?.keyStore).toBe("disabled");
expect(model?.keyStatus?.disabled).toBe(true);
expect(model?.keyStatus?.reason).toBeTruthy();
});
});
/* ------------------------------------------------------------------ */
/* Live binding — real routes against a stubbed fetch */
/* ------------------------------------------------------------------ */
type RouteHandler = (
url: string,
init?: RequestInit,
body?: string,
) => { status: number; body: unknown };
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : undefined;
const url = request ? request.url : String(input);
// openapi-fetch serializes the body onto the Request object —
// surface it (plus credentials/headers) back to the route handler
// so the assertions see exactly what went over the wire.
// request.body is a ReadableStream — read it back out.
let body: string | undefined;
let sentInit: RequestInit | undefined = init;
if (request) {
const cloned = request.clone();
body = cloned.body ? await cloned.text() : undefined;
sentInit = {
...init,
method: request.method,
credentials: request.credentials,
headers: request.headers,
};
}
for (const [prefix, handler] of Object.entries(routes)) {
if (url.startsWith(prefix)) {
const { status, body: responseBody } = handler(url, sentInit, body);
return new Response(JSON.stringify(responseBody), {
status,
headers: { "Content-Type": "application/json" },
});
}
}
return new Response(JSON.stringify({ detail: "not found" }), { status: 404 });
}) as typeof globalThis.fetch;
}
/** Headers actually sent on a call (CSRF + cookie assertions). */
function headersOf(init?: RequestInit): Record<string, string> {
const raw = init?.headers;
const record: Record<string, string> = {};
if (
typeof raw === "object" &&
raw !== null &&
typeof (raw as Headers).forEach === "function"
) {
(raw as Headers).forEach((v, k) => {
record[k] = v;
});
} else if (Array.isArray(raw)) {
for (const [k, v] of raw) record[String(k)] = String(v);
} else if (raw) {
Object.assign(record, raw);
}
return record;
}
const backendUserSettings = {
planner_model: "gpt-oss:20b",
writer_model: "gpt-oss:20b",
methodology_defaults: {},
appearance: "system",
};
const backendKeyStatus = {
configured: true,
masked: "…7f2a",
disabled: false,
reason: null,
};
const backendConfigStatus = {
resolved_tier: "user",
key_store: "enabled",
key_status: backendKeyStatus,
model_adapter: "OllamaModelAdapter",
search_adapter: "TavilySearchAdapter",
storage_dir: "/var/lib/researchx/runs",
probe: { endpoint: "https://ollama.com/v1", structured_outputs_supported: true },
cost_counters: { runs_created: 4, runs_completed: 3 },
};
describe("GeneratedApiClient per-user settings surface (live binding, T-2.9)", () => {
it("getUserSettings hits GET /api/config/settings with cookie discipline", async () => {
const routes = {
"http://127.0.0.1:8000/api/config/settings": (_url: string, init?: RequestInit) => {
// GET, never a mutation — but the cookie still rides along.
expect(init?.credentials).toBe("include");
return { status: 200, body: backendUserSettings };
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
const settings = await client.getUserSettings();
expect(settings.planner_model).toBe("gpt-oss:20b");
expect(settings.appearance).toBe("system");
});
it("saveUserSettings PUTs a partial body with the CSRF header", async () => {
const routes = {
"http://127.0.0.1:8000/api/config/settings": (
_url: string,
init?: RequestInit,
body?: string,
) => {
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
// Partial body: ONLY the field the caller set travels.
const parsed = JSON.parse(body ?? "null") as Record<string, unknown>;
expect(Object.keys(parsed)).toEqual(["planner_model"]);
return {
status: 200,
body: { ...backendUserSettings, planner_model: "gpt-oss:120b" },
};
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
const saved = await client.saveUserSettings({ planner_model: "gpt-oss:120b" });
expect(saved.planner_model).toBe("gpt-oss:120b");
});
it("setModelKey PUTs the raw key once and resolves ONLY the masked status", async () => {
const routes = {
"http://127.0.0.1:8000/api/config/settings/model-key": (
_url: string,
init?: RequestInit,
body?: string,
) => {
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
const parsed = JSON.parse(body ?? "null") as { key: string };
expect(parsed.key).toBe("sk-live-raw-secret-7f2a");
// The response is masked-only — the raw key never echoes.
return { status: 200, body: backendKeyStatus };
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
const status = await client.setModelKey("sk-live-raw-secret-7f2a");
expect(status.configured).toBe(true);
expect(status.masked).toBe("…7f2a");
expect(JSON.stringify(status)).not.toContain("sk-live-raw-secret-7f2a");
});
it("setModelKey surfaces a disabled key store as a typed 409 with the honest reason", async () => {
const routes = {
"http://127.0.0.1:8000/api/config/settings/model-key": () => ({
status: 409,
body: {
detail: {
key_store: "disabled",
reason:
"no RESEARCHX_KEK configured (env-file secrets 'keys' scope); per-user model keys cannot be stored",
},
},
}),
};
const client = new GeneratedApiClient(stubRoutes(routes));
const error = await client
.setModelKey("sk-anything")
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(KeyStoreDisabledError);
const typed = error as KeyStoreDisabledError;
expect(typed.reason).toContain("RESEARCHX_KEK");
// Honest refusal — never a silent no-op, never the raw key.
expect(JSON.stringify(typed)).not.toContain("sk-anything");
});
it("getConfigStatus maps the full config payload to the frontend shape", async () => {
const routes = {
"http://127.0.0.1:8000/api/config": (_url: string, init?: RequestInit) => {
expect(init?.credentials).toBe("include");
return { status: 200, body: backendConfigStatus };
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
const config = await client.getConfigStatus();
expect(config.resolved_tier).toBe("user");
expect(config.key_store).toBe("enabled");
expect(config.key_status.masked).toBe("…7f2a");
expect(config.model_adapter).toBe("OllamaModelAdapter");
expect(config.storage_dir).toBe("/var/lib/researchx/runs");
expect(config.probe).not.toBeNull();
expect(config.cost_counters).toEqual({ runs_created: 4, runs_completed: 3 });
});
it("getConfigStatus tolerates a keyless degraded backend honestly", async () => {
const routes = {
"http://127.0.0.1:8000/api/config": () => ({
status: 200,
body: {
resolved_tier: "keyless",
key_store: "disabled",
key_status: {
configured: false,
masked: null,
disabled: true,
reason: "no RESEARCHX_KEK configured",
},
model_adapter: "OllamaModelAdapter",
search_adapter: "DuckDuckGoSearchAdapter",
storage_dir: "/var/lib/researchx/runs",
probe: null,
cost_counters: { runs_created: 0, runs_completed: 0 },
},
}),
};
const client = new GeneratedApiClient(stubRoutes(routes));
const config = await client.getConfigStatus();
expect(config.resolved_tier).toBe("keyless");
expect(config.key_store).toBe("disabled");
expect(config.key_status.disabled).toBe(true);
expect(config.key_status.reason).toContain("RESEARCHX_KEK");
expect(config.cost_counters).toEqual({ runs_created: 0, runs_completed: 0 });
});
});
+121 -3
View File
@@ -382,6 +382,34 @@ export interface ConnectionSlot {
status: "connected" | "fallback" | "preview";
/** Human detail, e.g. "Ollama cloud — key detected" or "Preview until v0.2". */
detail: string;
/* ------------------------------------------------------------------ */
/* v0.3 P2 per-user credential fields (T-2.9, REQ-74). The resolved */
/* tier applies to the "model" slot; the key fields mirror the */
/* backend's GET /api/config. Mock mode fills honest fixture values */
/* (keyless / key store disabled). Older consumers that only read */
/* slot/label/status/detail stay source-compatible. */
/* ------------------------------------------------------------------ */
/** Credential tier this user's runs resolve to (model slot only). */
resolvedTier?: "user" | "server" | "keyless";
/** Whether the per-user key store is enabled server-side. */
keyStore?: "enabled" | "disabled";
/** Masked-only per-user model-key status — never the raw key. */
keyStatus?: ModelKeyStatus;
}
/**
* Masked-only model-key status (P2, REQ-73 — mirrors the backend's
* KeyStatus). The raw key NEVER appears in any field.
*/
export interface ModelKeyStatus {
/** True when a key is stored for the current user. */
configured: boolean;
/** Masked tail — "…7f2a" shape; null when nothing is stored. */
masked: string | null;
/** True when the key store is disabled server-side (no KEK). */
disabled: boolean;
/** Honest reason for a disabled/unreadable store — surfaced to the UI. */
reason: string | null;
}
/** The whole settings payload (maps to GET/PUT /api/config in v0.2). */
@@ -397,9 +425,71 @@ 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). */
/**
* Per-user settings (P2, REQ-72 — mirrors the backend's UserSettings
* from GET/PUT /api/config/settings). Model names + methodology
* defaults + appearance, one payload per user; fresh users get the
* server defaults (planner/writer "gpt-oss:20b", appearance "system").
*/
export interface UserSettings {
/** Model name the planner role runs (e.g. "gpt-oss:20b"). */
planner_model: string;
/** Model name the writer role runs. */
writer_model: string;
/** Resolved dial overrides per methodology — free-form, validated at use. */
methodology_defaults: Record<string, unknown>;
/** Appearance slot — "system" (the light palette is the only render today). */
appearance: string;
}
/**
* The per-user credential/usage status from GET /api/config (P2,
* REQ-74) — the live-mode settings surface. Every field is
* display-only except keyStatus, which drives the masked key entry.
*/
export interface ConfigStatus {
/** Credential tier the CURRENT user's runs resolve to. */
resolved_tier: "user" | "server" | "keyless";
/** Whether the server's per-user key store is enabled. */
key_store: "enabled" | "disabled";
/** Masked-only key status — never the raw key. */
key_status: ModelKeyStatus;
/** Adapter class names (display-only). */
model_adapter: string;
search_adapter: string;
/** Server-managed storage directory (display-only — D-111, no picker). */
storage_dir: string;
/** Capability-probe snapshot, when the adapter supports probing. */
probe: Record<string, unknown> | null;
/** Per-user usage counters (P-13). */
cost_counters: {
runs_created: number;
runs_completed: number;
};
}
/**
* A typed key-store refusal (P2, REQ-73): PUT model-key answered 409
* because the server's key store is disabled (no KEK). Carries the
* honest reason so the settings UI can say WHY, never a silent no-op.
*/
export class KeyStoreDisabledError extends Error {
readonly reason: string;
constructor(reason: string) {
super(reason);
this.name = "KeyStoreDisabledError";
this.reason = reason;
}
}
/**
* The menu/display profile behind the avatar button (REQ-66).
* v0.2 prototype shape — name + initials + tagline for the local
* 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 +497,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) */
/* ------------------------------------------------------------------ */