4 Commits

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

Ship record: PR #17 squash-merged phase/01-auth-sessions-scoping →
milestone/v0.3-hosted (2a61433), tag v0.3.1, phase branch deleted
(local+remote), Gitea release v0.3.1 created. Gates green at the tag
(backend 184 passed + ruff + mypy; frontend 119 passed + tsc + build
+ drift gate). REQUIREMENTS traceability REQ-69..71 → complete;
ROADMAP P1 → complete, P2 → next.
2026-09-29 21:36:50 +00:00
coreci-bot 2a61433e6c feat(P01): auth + sessions + user scoping (#17)
Squash-merge of phase/01-auth-sessions-scoping into milestone/v0.3-hosted. REQ-69/70/71 covered. Auth package (argon2id users, server-side sessions, guard+CSRF, admin routes), user-scoped storage partitioning, owner-keyed registry, restart sweep (G-101), registry pruning, frontend session surface (login, gate, user menu, ApiClient extensions), openapi snapshot regen + drift gate. Security: P0 register race + P1 corrupt-store fail-closed fixed in-phase. Verify: PASS 4/4 layers, 184+119 tests. Ships as v0.3.1.
2026-09-29 21:36:31 +00:00
coreci 60f858ab99 docs(P00): phase 0 shipped — checkpoint advances to phase 1
---ci---
project: researchx
phase: 0
milestone: v0.3
status: complete
requirements:
  covered: []
  partial: []
---/ci---

Ship record: PR #16 squash-merged phase/00-pre-execution →
milestone/v0.3-hosted (1adfa87), tag v0.3.0, phase branch deleted
(local+remote), Gitea release v0.3.0 created. Gates green at the tag
(backend 130 passed + ruff + mypy; frontend 97 passed + tsc + eslint).
ROADMAP P0 → complete. P1 (auth + sessions + user scoping, REQ-69..71)
is next.
2026-09-29 17:14:09 +00:00
coreci-bot 1adfa878d7 docs(P00): complete pre-execution phase — v0.3 hosted (#16)
Squash-merge of phase/00-pre-execution into milestone/v0.3-hosted. v0.3 pre-execution complete: REQ-69..85 specified (supersession recorded), D-101..D-115 clarified, RESEARCH-v03 (12 topics, C3-01..20, pitfalls P-1..14), PLAN P1..P4 vertical slices with MVP/UX sections, grill Proceed 0.68 with G-101..G-110 bound + G-111 escalated. Ships as v0.3.0.
2026-09-29 17:13:32 +00:00
67 changed files with 5793 additions and 437 deletions
+42 -31
View File
@@ -1,7 +1,7 @@
# Architecture
## Overview
Two-milestone build (owner-directed 2026-09-27). Milestone v0.1 builds the UI/UX prototype as the production Next.js frontend with all data access behind a typed `ApiClient` interface backed by `MockApiClient` — no backend, zero throwaway work. Milestone v0.2 (gated on prototype approval) adds the FastAPI backend, the custom async research engine, and the adapter layer, then swaps `MockApiClient` for the OpenAPI-generated client. The engine is a hand-rolled asyncio state machine; every run persists its resolved 12-dial configuration and full event trace for reproducibility and auditability.
Three-milestone build (owner-directed). Milestone v0.1 (COMPLETE) built the UI/UX prototype as the production Next.js frontend behind a typed `ApiClient`/`MockApiClient` boundary. Milestone v0.2 (COMPLETE) added the FastAPI backend, custom async research engine, adapter layer, and trust loop, then swapped MockApiClient for the OpenAPI-generated client. Milestone v0.3 — **Hosted** — converts the product to a hosted multi-user service: auth + server-side sessions, per-user encrypted model keys, user-scoped storage and surfaces, and a two-service deployment behind an SSE-safe reverse proxy. The engine keeps its hand-rolled asyncio state machine; every run persists resolved dials, full trace, and structured report/evidence artifacts for reproducibility and auditability.
## Components
@@ -13,61 +13,72 @@ Typed pydantic schema for the 12 dials (source universe, search routing, query s
### 2. Adapter Layer (`src/researchx/adapters/`)
Five pluggable slots with versioned contracts:
- **Model**: any OpenAI-compatible endpoint (covers Ollama cloud/local and vLLM); separate planner/writer roles; capability probe on connect (context window estimate, conservative request cap = min(model claim, 8192), embedding batch 8) surfaced as UI overrides; structured-output capability warning. Default: ollama.com/v1 cloud-first with localhost:11434/v1 fallback.
- **Model**: any OpenAI-compatible endpoint (covers Ollama cloud/local and vLLM); separate planner/writer roles; capability probe on connect (context window estimate, conservative request cap = min(model claim, 8192), embedding batch 8) surfaced as UI overrides; structured-output capability warning. **v0.3:** adapters are constructed **per run** by the Model Adapter Factory (`model_factory.py`) — no shared instance; the per-instance fallback (cloud → localhost) preserves REQ-08 semantics without cross-run mutation; optional `api_key` constructor param carries the resolved per-user credential.
- **Search**: Tavily default; DuckDuckGo keyless fallback; interface ready for Exa/Perplexity later.
- **Storage**: local filesystem adapter; reports as plain Markdown/JSON under user-chosen directory; run state as JSON after each transition.
- **Memory**: off in v1.0 — interface stubbed for semantic caching.
- **Secrets**: env-file provider in v1.0 — interface stubbed for OpenBao.
- **Storage**: local filesystem adapter; reports as plain Markdown/JSON under a user-chosen local directory; run state as JSON after each transition. **v0.3:** owner-scoped `LocalStorageAdapter(base_dir, owner_id)` instances — run layout `runs/<user_id>/<run_id>/` (supersedes D-010); data root via `RESEARCHX_DATA_DIR` (`researchx.config.paths.data_root()`); protocol gains `append_run_artifact`.
- **Memory**: off — interface stubbed for semantic caching.
- **Secrets**: env-file provider — interface stubbed for OpenBao. **v0.3:** the scopes map gains a `'keys'` scope (`RESEARCHX_KEK`) consumed by the per-user key store.
- **Boundaries:** all external I/O lives here; engine never imports vendor SDKs directly.
- **DependsOn:** config (dial schemas only)
### 3. Research Engine (`src/researchx/engine/`)
Custom asyncio state machine: PLANNING → AWAITING_APPROVAL → SEARCHING → EXTRACTING → REFLECTING → (depth loop) → SYNTHESIZING → VERIFYING → DONE. One RunActor per run; durable JSON state persisted to storage after each transition (pause/cancel/resume); typed event bus feeding SSE live trace; plan (sub-questions + source universe + outline) halts at AWAITING_APPROVAL until approved/edited.
### 3. Identity & Sessions (`src/researchx/auth/`) — NEW v0.3
User model (8-char slug `user_id`, email login, argon2id password hashing via argon2-cffi, all hashing off the event loop), server-side session store (opaque `token_urlsafe(32)` httpOnly SameSite=Lax cookies; in-process dict + durable `sessions.json` at the data root; 14-day sliding expiry; immediate revocation on logout/deactivation; token rotation at login), auth guard as a router-level dependency (`Depends(require_user)`) covering all `/api/*` except login/register, CSRF check (custom header on non-GET), first-user-is-admin + admin-managed accounts (`/api/admin/users`), user store `users.json` at the data root.
- **Boundaries:** owns user/session data + password hashing; no run knowledge.
- **DependsOn:** adapters (secrets for the KEK scope), storage (data-root persistence)
### 4. Per-User Key Store (`src/researchx/auth/key_store.py`) — NEW v0.3
AES-256-GCM envelope keyed by `RESEARCHX_KEK` (env-file 'keys' scope, 32 bytes b64/hex), `user_id` as AAD (ciphertext is bound to its owner — swapped files fail `InvalidTag`), `os.urandom(12)` nonce per write, `keys/<user_id>.json` (v1/entries format, atomic rewrite, 0600). API surface is masked-only (`configured` + last-4). Without a configured KEK the store is disabled with honest status; the resolution chain falls to server/keyless.
- **Boundaries:** owns key encryption/decryption; never returns key material.
- **DependsOn:** Identity, adapters/secrets
### 5. Model Adapter Factory (`src/researchx/adapters/model_factory.py`) — NEW v0.3
Per-run credential resolution (REQ-74): user's own key → server env-file key → keyless localhost:11434; constructs one model adapter per run and returns the resolved tier (`user|server|keyless`); the capability probe runs against the resolved tier; `GET /api/config` resolves the factory for the current user without creating a run (fingerprint-cached probe). Fixes the v0.2 shared-adapter fallback-mutation bug by construction.
- **Boundaries:** resolves credentials and constructs adapters; no engine knowledge.
- **DependsOn:** Identity (user), Key Store, adapters (model, secrets)
### 6. Research Engine (`src/researchx/engine/`)
Custom asyncio state machine: PLANNING → AWAITING_APPROVAL → SEARCHING → EXTRACTING → REFLECTING → (depth loop) → SYNTHESIZING → VERIFYING → DONE. One RunActor per run; durable JSON state persisted to storage after each transition (pause/cancel/resume); typed event bus feeding SSE live trace; plan (sub-questions + source universe + outline) halts at AWAITING_APPROVAL until approved/edited. **v0.3:** `RunState` carries `owner_id`; at DONE the engine persists two additional artifacts — `report.json` (structured sections + anchored citations with quotes) and `evidence.json` (passage texts) — written in the verify phase before the evidence store is discarded; citation markers are normalized `[p-N]` → `[^pN]` at the report.json boundary (report.md keeps the trust-loop form); startup sweep marks in-flight runs CANCELLED on process restart (rehydration out of v0.3 scope).
- **Boundaries:** orchestrates; contains no HTTP handling and no vendor I/O.
- **DependsOn:** adapters, methodologies, trust, evidence
### 4. Evidence Store (`src/researchx/evidence/`)
Visited-page store with stable passage IDs; passage extraction from fetched pages. The only source of writer context — the visited-only citation constraint is enforced by construction: the writer sees nothing else.
### 7. Evidence Store (`src/researchx/evidence/`)
Visited-page store with stable passage IDs; passage extraction from fetched pages. The only source of writer context — the visited-only citation constraint is enforced by construction: the writer sees nothing else. **v0.3:** serialized to `evidence.json` at DONE (passage texts survive the actor).
- **Boundaries:** owns evidence data model.
- **DependsOn:** adapters (search/storage only)
### 5. Trust Loop (`src/researchx/trust/`)
### 8. Trust Loop (`src/researchx/trust/`)
Runs inside every methodology: generation-time visited-only constraint [E1]; post-run URL liveness pass with live/stale/dead classification [E1]; claim-to-passage quote anchoring (fuzzy match against stored passages) [E1b]; citation ledger JSON per run (URL, visited_at, passages, claims supported, health, authority tier); verification checklist export.
- **Boundaries:** read side of evidence store; verification reports feed the ledger.
- **DependsOn:** evidence, adapters (model for verification passes)
### 6. API Layer (`src/researchx/api/`)
FastAPI routes: POST /api/runs, GET /api/methodologies, GET /api/runs/{id}, approve/pause/cancel/resume, GET /api/runs/{id}/events (SSE), GET /api/config. OpenAPI schema generates the typed frontend client.
### 9. API Layer (`src/researchx/api/`)
FastAPI routes — **v0.3 inventory**: `/api/auth/{register,login,logout,me}` (public router; register open only while the user store is empty), `/api/admin/users*` (admin-guarded), `POST /api/runs`, `GET /api/methodologies`, `GET /api/runs/{id}`, approve/pause/cancel/resume, `GET /api/runs/{id}/events` (SSE), `GET /api/runs/{id}/report` (enriched: markdown + structured report + evidence), `GET /api/runs/{id}/ledger`, `GET /api/config` (read-only, user-contextualized: resolved tier, key-store status, per-user cost counters, probe), `GET/PUT /api/config/settings` (per-user settings), `GET /api/library`, `GET /api/library/connections`, `GET /api/graph`, `GET /api/tasks`, `GET /api/suggestions`, `POST /api/refine/{questions,compose}`, `GET/PUT /api/profile`, and `/healthz` outside `/api` (unauthenticated, for proxy health checks). Read-model routes (library/connections/graph/tasks/suggestions/profile/settings) read disk artifacts only — never the RunRegistry; the registry is owner-keyed `(owner_id, run_id)` and pruned of terminal runs. OpenAPI schema generates the typed frontend client.
- **Boundaries:** transport only — no business logic.
- **DependsOn:** engine, methodologies, config
- **DependsOn:** engine, methodologies, config, auth, key store, model factory
### 7. Frontend (Next.js App Router, TypeScript strict)
App shell (root layout, P5): persistent sidebar (primary nav — New research / Library / Settings; adapter connection-status dots as a v2 preview) + top bar (⌘K navigation palette — screens + stored reports; avatar button → user menu with the local single-user profile). Screens: Ask (question + optional context; open-question suggestions below the form — follow-ups from unanswered questions of past runs, click-fills-the-box, REQ-67; opt-in Refine dialogue — inline drawer, scripted clarifying exchange, composed refined question + suggested context, REQ-68), Choose (methodology cards with ETA/cost/why-recommended; dials behind "advanced"), Plan & Run (approve/edit plan, live SSE trace, pause/cancel), Reading Room v0 (rendered report, hover-a-citation → exact quoted passage, ledger panel, Markdown/JSON export), Library (P5: folder-structure report storage — auto-categorized folders, recency/title sorting, report cards into the Reading Room; cross-report Connections panel — shared-sources ⇄ / related-topic → edges; project graph + task inbox as v2-preview panels), Settings (P5: masked API-key status, storage directory, default methodology + resolved 12-dial display, appearance preview, about/version).
### 10. Frontend (Next.js App Router, TypeScript strict)
App shell (persistent sidebar + top bar with ⌘K slot + avatar). **v0.3 additions:** login screen; session-aware shell (avatar menu → real profile, logout); live binding of ALL surfaces (the G-002 mock-delegation boundary is lifted — `GeneratedApiClient` becomes a standalone `ApiClient` implementation with no `MockApiClient` inheritance); auth + CSRF fetch wrapper (`credentials: "include"`, `X-ResearchX-Request` on mutations); EventSource `withCredentials`; two build profiles (mock dev default; hosted build inlines `NEXT_PUBLIC_RESEARCHX_API_MODE=live` + empty same-origin base URL). Screens: Ask (question + context; suggestions below the form; opt-in refine dialogue — now real planner calls), Choose (methodology cards + dials drawer), Plan & Run (approve/edit, live SSE trace, pause/cancel/resume), Reading Room (structured report with anchored hover citations from report.json/evidence.json, ledger panel, exports), Library (folder structure, report cards, connections), Settings (per-user: masked model key, methodology defaults, model names, display-only storage), profile.
- **Boundaries:** consumes the typed `ApiClient` interface only — components never import the mock client directly. In v0.1 the interface is bound to `MockApiClient` (fixture data + scripted event replay); in v0.2 phase 3 the binding swaps to the OpenAPI-generated client with no component changes.
- **DependsOn:** ApiClient contract (v0.1: mock; v0.2: API layer)
- **Boundaries:** consumes the typed `ApiClient` interface only — components never import the mock or generated client directly; the boundary selects the binding by build profile.
- **DependsOn:** ApiClient contract
## Data Flow
v0.1 (prototype): Ask (question + context) → Choose (methodology slug + dial overrides) → MockApiClient returns scripted plan → Plan & Run (scripted live trace replay, pause/cancel) → Reading Room (mock report with anchored citations, ledger, real file exports). All flows exercise the exact component paths the MVP will use.
v0.3 (hosted): browser → HAProxy (TLS, `/api` + `/healthz` → backend :8000, SSE-safe: no response buffering, no API-backend compression, 3600s timeouts) → FastAPI (session guard resolves user → per-user settings + key store → model factory resolves credential tier user→server→keyless → owner-scoped engine run) → durable artifacts under `runs/<user_id>/<run_id>/` → read-models scan disk → Library/Graph/Tasks/Suggestions render → Reading Room renders report.json with anchored citations (hover quotes from evidence.json) → exports round-trip real data. Engine internals (state machine, evidence, trust loop) unchanged from v0.2.
v0.2 (MVP): Ask (question + context) → Choose (methodology slug + dial overrides) → POST /api/runs → engine PLANNING (planner model via model adapter, dial bundle resolved) → AWAITING_APPROVAL (plan persisted; SSE events; user approves/edits) → SEARCHING (search adapter, routing per dials) → EXTRACTING (fetch pages → evidence store, stable passage IDs) → REFLECTING (planner model; depth loop per dials) → SYNTHESIZING (writer model; context = evidence passages only, per-claim citation markers) → VERIFYING (liveness pass, quote anchoring, confidence labels) → DONE (report + ledger + checklist persisted to storage; Reading Room renders; export).
## Deployment Topology (NEW v0.3)
Two containers on the LXC host behind the existing HAProxy/TLS front (research.coreci.dev): `researchx` (Next.js frontend, :3000, live build args) and `researchx-api` (FastAPI backend, :8000 host-published, non-root uid 10001, `uvicorn --workers 1` — mandatory: in-memory registry + session store), `researchx-data` volume at `/var/lib/researchx` (RESEARCHX_DATA_DIR), runtime secrets via `--env-file` only. Rollback pairs `researchx-prev` / `researchx-api-prev`. deploy.sh builds both images at the tag, health-checks `/healthz` (backend) + `/ask` (frontend), and asserts live mode in the smoke test.
## Build Order
Milestone v0.1 (prototype): 1. Frontend scaffold + ApiClient/MockApiClient boundary → 2. Ask + Choose → 3. Plan & Run + Reading Room v0 → 4. v2 mockups + polish + 3-click validation → 5. review + owner approval gate (owner returned gate feedback → extension cycle: 5. app shell + research library + settings + avatar → 6. final review + approval re-presentation (2nd owner gate feedback: 7. ask suggestions + refine dialogue → 8. final re-review + ship)).
Milestone v0.1 (prototype — COMPLETE): frontend scaffold → Ask/Choose → Plan & Run/Reading Room → mockups/3-click → owner-gate extensions (app shell, library, settings, avatar; suggestions, refine) → approval.
Milestone v0.2 (MVP — COMPLETE): methodology framework → adapters → evidence → trust → engine → API → client swap → adversarial + E2E → approval.
Milestone v0.3 (hosted): 1. Auth + sessions + user scoping (storage partitioning, registry owner-keying, auth suite) → 2. Per-user settings + encrypted key store + model factory (credential chain, probe-per-tier) → 3. Live surfaces (read-models on disk artifacts, report.json/evidence.json + Reading Room fidelity, refine/suggestions real calls, frontend live swaps, mock-delegation ends) → 4. Hosting (backend Dockerfile + production entrypoint, two-service deploy.sh, SSE-safe HAProxy, live-default hosted build) → 5. Final review + owner gate.
Milestone v0.2 (MVP, gated on v0.1 approval):
1. Methodology framework (dial schema + 2 seed methodologies + registry)
2. Adapter layer (model/search/storage contracts + local implementations; fake adapters for tests)
3. Evidence store (visited pages, passage extraction)
4. Trust loop (liveness, anchoring, ledger)
5. Engine (state machine, event bus, durable state, pause/resume)
6. API layer (routes + SSE)
7. Integration (swap MockApiClient → OpenAPI-generated client)
8. Adversarial test suite (hallucinated-URL detection, quote-mismatch detection, pause/resume mid-depth-loop) + E2E happy path
Build order is dependency-driven: trust verification requires evidence; engine requires adapters + trust; API requires engine; integration requires API; frontend components carry over unchanged from v0.1.
Build order is dependency-driven: auth precedes key store (identity); key store precedes the factory (credential chain); read-models precede hosting (disk-backed reads must exist before redeploys); hosting precedes the gate (the owner reviews the deployed thing).
+5 -5
View File
@@ -1,8 +1,8 @@
{
"phase": 5,
"phase": 1,
"stage": "complete",
"milestone": "v0.2",
"phase_role": "final",
"milestone": "v0.3",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-09-27T16:30:00Z"
}
"updated_at": "2026-09-29T21:40:00Z"
}
+214 -1
View File
@@ -216,4 +216,217 @@ The plan is sound with fixable gaps. The evidence base is unusually strong for a
### Escalations
- [G-009] Axis 2 — Owner ratification required at the P5 gate (D-018 halt): the v0.2 MVP demonstrates the **live core research loop only**; the remaining approved-prototype surfaces (Library, Settings persistence, Suggestions, Refine, graph/inbox previews, profile) remain mock-bound until v0.3+, and the live-mode gate demo requires a local backend + keys (or runs in mock mode) since D-019 forbids deploys. This redefines what "MVP done" looks like at the demo and must not be discovered by the owner at the gate — it must be ratified in advance. Engineering-side handling is bound in G-002/G-003; the ratification itself is unresolved until the owner records it.
- **RESOLVED 2026-09-27: RATIFIED.** Owner approval recorded via /ci-run 'APPROVED' at the v0.2.5 gate (decision commit 4721c2a), following the owner-directed UAT staging at https://research.coreci.dev. Live-mode scope = core research loop only is the accepted MVP boundary; mock-bound surfaces move to v0.3+.
- **RESOLVED 2026-09-27: RATIFIED.** Owner approval recorded via /ci-run 'APPROVED' at the v0.2.5 gate (decision commit 4721c2a), following the owner-directed UAT staging at https://research.coreci.dev. Live-mode scope = core research loop only is the accepted MVP boundary; mock-bound surfaces move to v0.3+.
# CIAgent Grill Report
## Run: 2026-09-29 17:05 (mode: mechanical, focus: all)
### Verdict: Proceed (confidence: 0.68)
The plan survives adversarial re-verification better than any artifact this project has produced: the grill independently re-checked the research file's load-bearing claims against the tree and found them exact — the shared-adapter fallback mutation (`ollama.py:73-79`), the `config_status` private-registry read (`app.py:174-177`), the CORS-credentials gap (`app.py:143-148`), the fake-serving module global (`app.py:359`), the missing `append_run_artifact` protocol method, the `[p-N]`/`[^pN]` marker split (`actor.py:337` vs `report-body.ts:18`), the export fabrication (`live-client.ts:296-304/335/376`), and the 8-test-file partitioning blast radius. The auth supersession is traceable end to end (a4afccc constraint rewrite + D-101..D-115 @ 33f4fa0 + ROADMAP session note — matching the owner's "one milestone / admin-managed / server-key-fallback" session picks). Engine-contract preservation is a real design discipline, not a slogan. But the grill found **one "verified" research claim that is false** (the restart sweep's CANCELLED-from-any-state legality — `states.py` forbids PLANNING→CANCELLED), **three phase/bookkeeping contradictions** (REQ-75 triple-mapped across three documents; two stale out-of-scope rows contradicting the same-file supersession; C3-19's pitfall-phase mapping vs PLAN), **one unaddressed prototype-fidelity gap** (the task-inbox checkbox cannot survive the derived live read-model), and **an honestly-unbudgeted public attack surface** (no password policy, no login rate limit on a TLS-fronted public URL). All are absorbed as binding decisions G-101..G-110; one item is genuinely owner-required (G-111: P4's definition-of-done depends on a staged host deployment that D-019 forbids without a directive). The milestone is heavier than v0.2 — the P3 pressure valve absent from the plan is bound here (G-108) as the G-006 precedent. Proceed.
### Axis 1 — Business Case
- **Q1**: What problem does this actually solve, and is that problem still the top priority?
- Evidence: v0.2.5 approved and merged 2026-09-27 (8db45e4, b11b202); v0.3 scope owner-directed the next day, 2026-09-29 (a4afccc; ROADMAP "owner session 2026-09-29"); DEPLOY-RECORD.md itself records "live mode additionally needs the backend API on the host, which is v0.3+ deployment work" — the deployment gap is acknowledged in the ship record, not invented by the pipeline.
- Answer: Real and owner-named: convert the ratified mock-bound surfaces + single-user local loop into a hosted multi-user product with real inference. The same-day sequencing after v0.2.5 approval is the strongest available priority signal.
- Confidence: 0.80
- **Q2**: Who is the named executive sponsor, and when did they last make a decision under pressure?
- Evidence: Owner pressure-tested through v0.1 (two gate rejections → binding extensions, commits 57c5231, 3fd0e10), G-009 ratified at the v0.2 gate (4721c2a); the v0.3 direction itself is a decision under pressure — superseding a recorded constraint ("single-user, local-first") the same day the MVP shipped.
- Answer: Sponsor engaged and decisive; the supersession is a recorded constraint change, not scope drift.
- Confidence: 0.85
- **Q3**: What happens to the business if the project is cancelled?
- Evidence: v0.2.5 live mode = core research loop only, mock-default frontend, no hosting (G-009 ratification, ROADMAP v0.2 complete note); the owner-stated v0.3 purpose is the hosted product.
- Answer: Cancelling strands the G-009 promise ("mock-bound surfaces move to v0.3+") and leaves the product undeployable in its owner-intended form. Not a zombie — the payoff milestone.
- Confidence: 0.75
- **Q4**: Is the ROI calculated against a counterfactual?
- Evidence: MISSING — no formal ROI anywhere; G-007 counters carried advisory-only, owner-keyed in P2 (T-2.6, P-13). v0.3 adds real costs v0.2 did not carry: public infrastructure exposure and the server-tier key serving any admin-created user's runs.
- Answer: Still owner-judgment — tolerable for a sponsor-beneficiary product with an admin-bounded user set, but the server-key cost posture is now a stated-nowhere exposure; bound as G-110.
- Confidence: 0.55
- Decision: G-110 — document the cost posture: server-tier runs bill the owner's key; the bound is admin-managed accounts; per-user counters surface server-tier usage; hard caps deferred v0.4+.
### Axis 2 — Scope and Requirements
- **Q1**: Is the scope expanding, contracting, or genuinely stable?
- Evidence: Scope grew by owner directive (ROADMAP 2026-09-29 note); REQ-69..85 frozen with per-phase traceability; PLAN frontmatter carries all 17. But the G-001-class phase contradictions persisting: REQ-75 is mapped P3 by REQUIREMENTS traceability, P4-only by ROADMAP P3/P4 lists, and split by PLAN (delegation-end in P3, hosted in P4). C3-19 likewise maps P-1/P-2/P-13 to P1 while PLAN closes them in P2.
- Answer: Formal scope stable and owner-frozen; the traceability layer is where the drift lives — three documents disagree about one requirement. Bound in G-103.
- Confidence: 0.60
- Decision: G-103 — bind the REQ-75 split explicitly; fix the traceability row notation; declare PLAN's pitfall-closure mapping authoritative over C3-19's compressed prose.
- **Q2**: Who owns the requirements, and have they been frozen?
- Evidence: Owner-directed supersession traceable (a4afccc strikethrough + rationale; D-101..D-115 clarify commit 33f4fa0 at confidence 0.75–0.95).
- Answer: Owner owns; pipeline freezes and traces. The supersession discipline is exemplary — the constraint change is visible, dated, and strikethrough-preserved rather than silently rewritten.
- Confidence: 0.85
- **Q3**: What is explicitly out of scope?
- Evidence: **CONTRADICTION FOUND** — REQUIREMENTS.md Out-of-Scope table still carries "Auth / multi-tenancy | Single-user local-first MVP"; PROJECT.md Out of Scope still lists "multi-tenant/auth" — while PROJECT.md:34 records the supersession in the same file and REQ-69..71 define the now-in-scope auth. The honest deferrals (OAuth v0.4+, 6 methodologies v0.4+, KEK rotation, account deletion, rehydration) are all recorded.
- Answer: Mostly honest, with two stale rows that directly contradict a recorded decision — exactly the spec-comment drift class the v0.2 grill flagged. Bound in G-102.
- Confidence: 0.85
- Decision: G-102 — update both stale out-of-scope rows to match the supersession.
- **Q4**: Are there hidden requirements only disclosed late in delivery?
- Evidence: **FOUND — three.** (a) REQ-78 promises "checkable follow-ups"; `library-view.tsx:430-471` ships a working checkbox; the live read-model (D-114) derives status answered-iff-DONE over a GET-only route — a live user's check silently reverts on refetch; PLAN never addresses it. (b) No password minimum length exists anywhere (grep-verified). (c) No login rate limiting anywhere (grep-verified) — argon2 verify is ~50-100ms CPU, a public flood is a DoS amplifier on a single worker.
- Answer: The v0.2 G-002 lesson recurs once (the toggle); the exposure items are new-hosting hidden requirements. All three bound (G-109, G-104).
- Confidence: 0.75
### Axis 3 — Architecture and Technical Feasibility
- **Q1**: Has the architecture been validated by the people who will build and operate it?
- Evidence: RESEARCH-v03 claims re-verified by this grill: 11 of 12 sampled claims exact. **One claim is FALSE**: RESEARCH §7/C3-11 "rewrite every non-terminal state.json as CANCELLED (legal from every non-terminal state, verified states.py)" — `states.py` `_LEGAL_TRANSITIONS[PLANNING] = {AWAITING_APPROVAL, FAILED}`: no CANCELLED. A crash mid-PLANNING leaves a persisted PLANNING state the sweep cannot legally cancel.
- Answer: Design-side validation is the strongest yet — but the single false "verified" claim is the precise failure mode mechanical research invites: verification asserted, not re-run. Bound in G-101.
- Confidence: 0.85
- Decision: G-101 — startup sweep must handle PLANNING-state runs: add PLANNING→CANCELLED to `_LEGAL_TRANSITIONS` or document the sweep as an explicit transition-validation bypass; pinned by test in T-1.8.
- **Q2**: What is the integration surface, and where does it quietly double in cost?
- Evidence: Five seams: auth dependency threading (P1), per-run factory + probe-per-tier (P2), disk read-models (P3), snapshot regen ×3 (G-005 wired into P1/P2/P3), HAProxy front extension (P4). The cost-doubler candidate is SSE-through-proxy: the only seam whose live configuration is deferred to deploy time.
- Answer: Residual risk is bounded, not eliminated: the P4 smoke asserts a full unbuffered SSE run through the proxy path with the `-prev` rollback pair retained — worst case is one deploy iteration, not milestone rework. Accepted with the G-111 governance condition.
- Confidence: 0.65
- **Q3**: Is there an existing system being replaced?
- Evidence: Greenfield hosting; pre-hosted artifacts explicitly disposable (D-106, owner-recorded); the "replaced system" is the mock delegation layer (live-client.ts:61) — deletion is D-112/T-3.8 with component tests proving zero rewrites (T-3.12). Volume model: admin-managed accounts (REQ-70) bound the user set; single worker mandatory.
- Answer: No migration risk; the swap is designed as a deletion with regression pins. The one fidelity trap (task-inbox toggle) is found and bound (G-109).
- Confidence: 0.75
- **Q4**: What is the technical debt being inherited, and is it budgeted for?
- Evidence: The 14-item pitfall ledger is budgeted per-phase (PLAN cross-phase notes) — with C3-19's compressed phase mapping contradicting PLAN (folded into G-103). Marker normalization: one-directional at the report.json boundary only; report.md raw form pinned by test; trust-loop regexes untouched.
- Answer: If a future writer emits `[^pN]` natively, `_cited_urls` matches nothing → ledger empties → the G-004 export gate fails visibly at runtime, and the raw-form pin catches it in CI first. Silent corruption is not reachable.
- Confidence: 0.70
### Axis 4 — People, Skills, and Organization
- **Q1**: Which 2–3 people, if they left, would the project fail?
- Evidence: One agent pipeline + one owner; gates owner-halt by design (D-018). The security-engineer persona is phase-specific P1/P2/P4 with an explicit removal note (PERSONAS.md).
- Answer: Total key-person dependency remains structural and accepted; the P5 full multi-persona review covers the security persona's absence.
- Confidence: 0.60
- **Q2**: Are resources actually allocated at the percentages claimed?
- Evidence: v0.1 shipped ~9 phases in ~1 day; v0.2 shipped P0..P5 in one day; v0.3 P0 commits same-day (a4afccc→dca86a7).
- Answer: Velocity verified by artifact trail. v0.3's four phases are each multi-domain — the evidence supports capacity, with the G-108 valve for the heaviest phase.
- Confidence: 0.80
- **Q3**: Is there a product owner with actual authority to prioritize?
- Evidence: Owner-directed supersession recorded same-day as MVP ship (a4afccc); G-009 ratified at gate (4721c2a); D-018/D-019 carry.
- Answer: Unambiguous single owner, exercised.
- Confidence: 0.90
- **Q4**: Is the team building capability it doesn't have, hoping to learn as they go?
- Evidence: argon2-cffi, AESGCM envelope encryption, server-side sessions, SSE-through-proxy are all new to the codebase; mitigations are research-verified concrete shapes + adversarial suites specced + a dedicated security-engineer persona on exactly the phases that carry the new capability.
- Answer: Yes — learning-while-doing on a security-critical public surface, the highest-stakes version of this risk. The mitigation structure is proportionate. Acceptable; the G-104 hardening items close the gaps the research did not self-identify.
- Confidence: 0.60
### Axis 5 — Timeline and Estimates
- **Q1**: Was the deadline set before or after scope and approach were understood?
- Evidence: No wall-clock deadlines anywhere; dependency-driven phases; owner gates as the only cadence.
- Answer: Healthiest pattern, unchanged from v0.2.
- Confidence: 0.85
- **Q2**: What is the critical path, and what single thing would push it by 3+ months?
- Evidence: P1→P2→P3→P4 sequential. P3 is the heaviest (7 REQs + standalone client + every surface swap + Reading Room fidelity + delegation-end); P1 is the widest (auth + partitioning across the 10-touchpoint list). The v0.2 pusher-class risk (integration drift at the swap) is pre-defused by G-005 wired into all three route-touching phases + the C3-18 exhaustive interface list.
- Answer: The pusher is P3 overrun with no pre-agreed valve — v0.2 had G-006; v0.3's plan had none. Bound in G-108.
- Confidence: 0.70
- Decision: G-108 — P3 may ship as two patches (P3a backend; P3b frontend) or run mid-phase wave gates, without scope change; tags extend on the v0.3.x line.
- **Q3**: Are the estimates evidence-based?
- Evidence: v0.1/v0.2 velocity does not transfer 1:1 (auth/crypto/hosting are new domains); no per-phase sizing exists. The honest mitigation remains the phase-patch mechanism — now with the P3 seam named in advance.
- Answer: No estimates to be wrong; the valve replaces fake numbers. Endorsed, per the v0.2 precedent.
- Confidence: 0.60
- **Q4**: Is there a working definition of done?
- Evidence: Per-phase must-haves are concretely testable (401 pins, two-user isolation, tamper pins, sweep test, drift gate, four smoke assertions); P5 owner gate. **One DoD term is undefined**: T-4.7/T-4.9 run the smoke "against a staged deploy" — "staged" is never defined, and the host runs one container today.
- Answer: DoD has teeth everywhere except the P4 smoke's target. Bound in G-107; its owner-contingency half escalates (G-111).
- Confidence: 0.75
- Decision: G-107 — "staged deploy" defined: candidate pair built + run on the LXC host, health green, the four smoke assertions executed host-locally against the candidates; public TLS-front cutover remains a discrete owner-directive act (D-019) with post-cutover public assertions run with the -prev pair armed.
### Axis 6 — Budget and Financial Realism
- **Q1**: What percentage of budget is spent vs. remaining?
- Evidence: MISSING — no cost ledger (v0.2 finding, carried). New v0.3 spends: second container family, argon2-cffi + cryptography (OSS), HAProxy front extension (existing instance), owner's Ollama key serving server-tier users.
- Answer: Untracked, small in absolute terms, tolerable for sponsor-beneficiary scope — but the server-tier exposure is a stated-nowhere cost posture (G-110).
- Confidence: 0.60
- **Q2**: Are there predictable cost drivers not in the original budget?
- Evidence: (a) Server-key tier: any admin-created user's keyless runs bill the owner (REQ-74 chain; G-007 counters advisory-only). (b) Host disk: deploy.sh (212 lines, verified) builds one image today with no free-space assert and no image prune; P4 doubles image families and runs 4 containers.
- Answer: Both are real; (a) is a policy posture to state (G-110), (b) is deploy hygiene to add (G-106).
- Confidence: 0.70
- Decision: G-106 — deploy.sh gains a pre-build free-disk assertion and a post-swap dangling-image prune; the two-image/rollback-pair footprint is documented as the host budget.
- **Q3**: Burn rate and runway?
- Evidence: Keyless localhost + DDG fallback keep the zero-marginal-cost degraded posture (G-008 carries); per-user keys shift burn to users; refine endpoints ride the same chain with honest 503.
- Answer: Runway effectively unbounded in degraded mode; hosting costs are fixed and small. Sound.
- Confidence: 0.70
- **Q4**: Is the budget contingent on something that hasn't happened yet?
- Evidence: Infra exists (LXC + HAProxy already TLS-fronting research.coreci.dev); keys exist in scopes. One new prerequisite: RESEARCHX_KEK does not exist yet (provisioned into `.ciagent/.env.secrets` at P2 — never git).
- Answer: Not contingent in any structural sense; the KEK is a trivial owner action — which is precisely why its loss posture must be stated before it becomes load-bearing (G-105).
- Confidence: 0.75
- Decision: G-105 — KEK-loss posture stated honestly: loss = per-user encrypted keys unrecoverable, chain degrades to server/keyless honestly, users re-enter keys under a re-provisioned KEK; the owner MUST back up the KEK off-repo; rotation tooling stays out of v0.3 (recorded, not silently dropped).
### Axis 7 — Risks, Assumptions, and Dependencies
- **Q1**: Top 3 assumptions, and evidence for each?
- Evidence: (a) *HAProxy live-host config accepts the SSE-safe front extension* — version and TLS front evidenced by two prior deploys; global/default sections unread until P4. Medium evidence. (b) *Single-worker in-memory registry/sessions suffice* — bounded by admin-managed accounts; restart sweep + pruning + disk read-models remove restart-fragility. Good evidence. (c) *Zero-component-rewrite surface swaps hold* — evidenced by the v0.2 P3 precedent + G-005 + T-3.12 regression suite; the task-toggle is the one discovered exception (G-109).
- Answer: Two well-evidenced; (a) is the residual, bounded by the P4 smoke + rollback pair.
- Confidence: 0.60
- **Q2**: External dependencies outside the team?
- Evidence: HAProxy host state (owner-held, D-019-gated), Ollama cloud/local + Tavily/DDG (carried v0.2, G-008 carries), Docker host disk (G-106 binds the missing hygiene).
- Answer: All acknowledged with degradation or rollback paths; the proxy is the only one without a code-level fallback, and it has a process-level one.
- Confidence: 0.65
- **Q3**: The single risk that kills the project, and is there a mitigation?
- Evidence: Authorization-class auth bugs on a public URL (cross-user leakage) — mitigated by construction: owner-keyed registry 404s + partitioned storage + per-request owner-scoped adapters + the two-user isolation test pinned in P1/P3 + the P4 exposure audit + admin-managed accounts bounding the victim set. The sweep-legality bug (G-101) is the found instance of the adjacent class.
- Answer: Kill risk is real but the mitigation stack is the plan's strongest suit; the found bug proves the stack needs the grill, and now it has the fix.
- Confidence: 0.70
- **Q4**: Has a pre-mortem been done?
- Evidence: MISSING as a formal artifact; this grill is the proxy.
- Answer: "12 months out, v0.3 failed" — derivable causes: (1) the sweep bug corrupting restarts at first redeploy (bound: G-101); (2) P3 fidelity parity slips — one found (toggle, G-109), the rest pinned by parity tests; (3) proxy/SSE iteration thrash post-cutover (bounded: smoke + rollback); (4) KEK lost with no documented posture (bound: G-105); (5) public brute-force/DoS on a fresh auth surface (bound: G-104). All five now bound or bounded-by-design.
- Confidence: 0.65
### Axis 8 — Governance, Decision-Making, and Communication
- **Q1**: Who is the decision-maker when two parties disagree?
- Evidence: Single owner; D-018/D-019 carry; escalation hooks codified (config.json).
- Answer: Unambiguous; no dual-executive stall possible.
- Confidence: 0.90
- **Q2**: Governance cadence and escalation pattern — useful in a crisis?
- Evidence: Per-phase ship + gates; the pipeline halted itself at the v0.2 gate correctly; deploy is owner-directive-only. **New v0.3 wrinkle**: P4's phase-ship DoD requires a host deployment that D-019 forbids without a directive — the first time a phase's success criteria are contingent on a mid-milestone owner action. T-4.9 acknowledges it but does not resolve the contingency.
- Answer: The escalation pattern is crisis-proven, but this dependency is new and unresolved — if the owner declines the directive at P4, REQ-83..85 ship unproven-on-host and the milestone gate presents hosted requirements on faith. Escalated as G-111.
- Confidence: 0.70
- **Q3**: What is being omitted from status reports?
- Evidence: `---ci---` honesty verified historically. The channel where optimism leaked this cycle is the research file: C3-11 asserts "verified states.py" for a claim that is false — asserted verification that was not re-run. This grill re-ran it.
- Answer: Status reporting remains honest; asserted-but-unre-run verification is the leak channel, and the fix is cultural (G-101's test pin makes the claim checkable).
- Confidence: 0.80
- **Q4**: Is there a stop-the-project trigger?
- Evidence: Owner rejection precedent (twice in v0.1); D-019 is the hosting-scope-specific stop trigger — no public cutover without directive; merge halts at the gate (D-018).
- Answer: Yes, and the hosting design adds a second one: the staged/public split (G-107) means exposure is a discrete owner decision, not a gradual slide.
- Confidence: 0.85
### Axis 9 — Change, Adoption, and Operational Readiness
- **Q1**: Who will use this, how does their work change, and what's in it for them?
- Evidence: Admin-managed accounts (REQ-70) — the owner creates the user set; work change = login + hosted real inference with per-user keys vs. local setup; UX Acceptance Criteria preserve the ≤3-click path with login as a once-per-session step.
- Answer: Small, known, sponsor-adjacent user set; adoption politics minimal, value concrete.
- Confidence: 0.70
- **Q2**: Is ops involved now, or handed a finished product at go-live?
- Evidence: The pipeline is ops; deploy.sh extends with paired rollback for both services (T-4.4); the startup sweep is the redeploy safety net (T-1.8); volume ownership handled (chown 10001, T-4.4). Gaps found: no disk assert/prune (G-106), undefined "staged" (G-107), KEK operational posture undocumented (G-105).
- Answer: Ops is built-in; three hygiene gaps bound.
- Confidence: 0.65
- **Q3**: What is the rollback plan?
- Evidence: Paired `-prev` containers for BOTH services (T-4.4); mock-dev default preserved at the code level (REQ-75/D-112); pre-hosted artifacts disposable (D-106); sessions durable across restarts (D-101).
- Answer: Multi-layer and codified — the strongest single dimension of this plan.
- Confidence: 0.80
- **Q4**: Have success criteria been validated with the people who will judge success?
- Evidence: ROADMAP v0.3 success criteria are concrete (401 curl, SSE unbuffered, two-user isolation, live-mode default on the staged instance); owner gate precedent enforces criteria. **Found**: REQ-78's own text promises "checkable follow-ups" while D-114/T-3.5 derive answered-iff-DONE over a GET-only route — the approved prototype's working checkbox (`library-view.tsx:463-471`) cannot hold live.
- Answer: One criterion-vs-implementation mismatch found; bound as honest degradation (G-109) rather than silent reversion.
- Confidence: 0.65
- Decision: G-109 — live-mode task status is derived (open unless run DONE); the inbox checkbox becomes display-honest in live mode (disabled/annotated; mock keeps the free toggle for dev); persisted per-question check state is v0.4+ scope (REQ-45 backlog); pinned by a live-mode component test.
### Meta — Closing Review
- **Q1**: If you were the auditor, what would you flag?
- Evidence: The false "verified" claim (C3-11 sweep legality vs `states.py` PLANNING); the REQ-75 triple mapping; the two stale out-of-scope rows contradicting the same-file supersession; C3-19 vs PLAN pitfall-phase mapping.
- Answer: Flag #1 is the sweep bug — a restart-path correctness claim that would have detonated at the first hosted redeploy, the exact moment the milestone's value proposition is under evaluation. Flags #2-4 are the recurring phase-mapping/bookkeeping class (G-001's relatives), now bound (G-102/G-103).
- Confidence: 0.80
- **Q2**: What is the project not doing that it should?
- Evidence: Found across axes: exposure hardening (password policy, login rate limit — G-104); KEK-loss posture (G-105); deploy disk hygiene (G-106); staged-deploy definition (G-107); P3 pressure valve (G-108); task-toggle honesty (G-109); cost posture statement (G-110).
- Answer: Seven omissions, all bound. Nothing found suggests the project is doing something it should not — engine-contract preservation holds everywhere checked.
- Confidence: 0.75
- **Q3**: What is the simplest version that delivers 80% of the value?
- Evidence: The owner explicitly picked "one milestone"; deferring live surfaces would re-create the G-009 mock-bound state the owner just moved past — the lever is not deferral.
- Answer: The pre-agreed lever is the P3 split (G-108), decided now while it is cheap — not surface deferral, which would contradict the owner's directive.
- Confidence: 0.65
- **Q4**: What must be true for this to succeed in the next 90 days, and is it true today?
- Evidence: (a) D-101..D-115 workable against the tree — re-verified (11/12 sampled exact, 1 false and bound). (b) Owner engagement — evidenced same-day. (c) The KEK provisioned by P2 — trivial owner action, posture bound (G-105). (d) The P4 staged host deployment authorized — currently NOT true; D-019 requires a directive the plan does not pre-arrange (G-111).
- Answer: (a)(b)(c) true or evidenced; (d) becomes true when the owner records the pre-authorization. That escalation is the gating action of this grill.
- Confidence: 0.70
### Binding Decisions
| ID | Axis | Decision | Confidence |
|----|------|----------|-----------|
| G-101 | 3/7 | Startup sweep must handle PLANNING-state runs: RESEARCH §7/C3-11's "CANCELLED legal from every non-terminal state" is false (`states.py`: PLANNING → {AWAITING_APPROVAL, FAILED}). Add PLANNING→CANCELLED to `_LEGAL_TRANSITIONS` or document the sweep as an explicit transition-validation bypass; pin with a PLANNING-fixture→CANCELLED sweep test in T-1.8. | 0.90 |
| G-102 | 2 | Fix the stale out-of-scope rows contradicting the recorded supersession: REQUIREMENTS.md Out-of-Scope ("Auth / multi-tenancy") and PROJECT.md Out of Scope ("multi-tenant/auth") — replaced with the honest v0.3 posture (auth/multi-user in scope; open self-serve registration and OAuth out; admin-managed accounts only, OAuth v0.4+). | 0.95 |
| G-103 | 2/5 | Phase-mapping integrity (G-001 precedent): bind REQ-75's split — delegation-end half closes in P3 (pinned by the standalone-client/no-mock-delegation tests), hosted-live-default half in P4 (pinned by the live-mode smoke marker); fix the traceability row (REQ-75..82 → REQ-76..82 + REQ-75 split note); declare PLAN's pitfall-closure mapping authoritative over C3-19's compressed phase prose. | 0.85 |
| G-104 | 2/3 | Public-exposure hardening, budgeted honestly: (a) minimum password length ≥12 enforced at register + admin-create (P1 T-1.4/T-1.5, uniform error messages preserved); (b) per-IP login rate limit at the HAProxy front (stick-table on /api/auth/*, P4 T-4.5) bounding argon2 CPU burn on the single worker; (c) the T-4.8 exposure audit asserts the throttle and confirms uniform 401s (no user enumeration). | 0.80 |
| G-105 | 6/7 | KEK-loss posture stated honestly (PLAN T-2.2 + owner docs): RESEARCHX_KEK's only copy is the env-file (never git); loss = per-user encrypted keys unrecoverable (AES-256-GCM + AAD), chain degrades to server/keyless honestly, users re-enter keys under a re-provisioned KEK; the owner MUST back up the KEK off-repo; rotation/re-wrap tooling stays out of v0.3 (recorded, not silently dropped). | 0.85 |
| G-106 | 6 | deploy.sh two-image hygiene: pre-build free-disk assertion, post-swap dangling-image prune, and a documented host budget (2 image families × live+prev, 4 containers) — the current script has neither, and P4 doubles the footprint on a single LXC host. | 0.75 |
| G-107 | 5/9 | "Staged deploy" defined: candidate pair (researchx + researchx-api) built + run on the LXC host, health checks green, the four smoke assertions executed host-locally against the candidates; public TLS-front cutover remains a discrete owner-directive act (D-019) with post-cutover public assertions (public 401, SSE through proxy) run with the -prev pair armed. | 0.75 |
| G-108 | 5 | G-006 precedent applied to P3: may ship as two patches (P3a backend artifacts + read-models + routes + read-model suite; P3b frontend standalone client + surface swaps + Reading Room fidelity + delegation-end) or run mid-phase wave gates, without scope change; tags extend on the v0.3.x line; the milestone release remains the final phase's patch. | 0.70 |
| G-109 | 9 | Task-inbox honesty (G-002 lesson): live-mode status is derived (answered-iff run DONE, D-114); the inbox checkbox (`library-view.tsx:463-471`) becomes display-honest in live mode — disabled/annotated, never a silent reversion; mock keeps the free toggle for dev; persisted per-question check state recorded as v0.4+ (REQ-45 backlog); pinned by a live-mode component test in T-3.12. | 0.75 |
| G-110 | 1/6 | Cost posture stated in PLAN/PROJECT: server-tier runs bill the owner's OLLAMA_API_KEY; the bound is the admin-managed account set (registration closed, REQ-70); per-user counters (P-13, T-2.6) surface server-tier usage per user for owner audit; no hard spend cap in v0.3 — hard caps are v0.4+ scope if the user set grows; keyless localhost remains the zero-cost path. | 0.70 |
### Escalations
- [G-111] Axis 5/8 — Owner pre-authorization required before P4 executes: P4's own definition of done ("staged smoke suite passes all four assertions", ROADMAP/PLAN T-4.7/T-4.9) requires deploying the candidate container pair to the LXC host — an action D-019 forbids without an owner directive. Without pre-authorization, P4 ships with REQ-83..85 unproven on the real host (the HAProxy 2.8 live config is unread until then) and the milestone gate would present hosting requirements on faith. The owner must either (a) pre-authorize staged host deployments for P4 DoD purposes (candidate pair + host-local smoke, NO public cutover — cutover stays a separate owner directive per G-107), or (b) accept that P4's phase ship carries hosting proven only on a local docker pair, with the host proofs deferred to the gate. This is the first phase in the project whose success criteria are contingent on a mid-milestone owner action; it must not be discovered at P4 ship time. Engineering-side handling is bound in G-107; the authorization itself is unresolved until the owner records it.
+17 -17
View File
@@ -1,4 +1,4 @@
# Personas — Milestone v0.2 (MVP)
# Personas — Milestone v0.3 (Hosted)
---
active:
@@ -8,8 +8,8 @@ active:
- name: ci-lead-developer
phase_specific:
- name: security-engineer
phases: [2]
reason: "v0.2 is the MVP: FastAPI backend + engine + adapters + trust loop (backend/data domains activate), the frontend consumes the OpenAPI-generated client in P3 (frontend stays active for the binding swap + P4 E2E), and the trust loop in P2 needs a phase-specific security-engineer for secrets handling and liveness verification. Lead-developer coordinates."
phases: [1, 2, 4]
reason: "v0.3 is the hosted multi-user milestone: P1 lands auth/sessions/CSRF/user-scoping, P2 lands the encrypted key store + credential chain, and P4 exposes the whole surface through a public proxy — security review is needed at all three, but not at P3 (live surfaces are read-model + binding work with no new secret handling) or P5 (the review phase runs the full multi-persona pass anyway)."
---
## Roster
@@ -18,8 +18,8 @@ reason: "v0.2 is the MVP: FastAPI backend + engine + adapters + trust loop (back
- **Domain**: backend
- **Active**: true
- **Phase-specific**: false
- **Frameworks**: fastapi, pydantic v2, uv, uvicorn, sse-starlette, httpx, pytest, pytest-asyncio
- **Constraints**: src-layout package `researchx` in backend/; all external I/O lives in adapters — engine imports no vendor SDKs (httpx only inside adapters); no blocking calls in async paths; durable state via atomic tmp+os.replace; never swallow CancelledError; API layer is transport only (no business logic); single uvicorn worker (single-user)
- **Frameworks**: fastapi, pydantic v2, argon2-cffi, cryptography, uv, uvicorn, sse-starlette, httpx, pytest, pytest-asyncio
- **Constraints**: single uvicorn worker mandatory (in-memory registry + session store); all external I/O lives in adapters — engine imports no vendor SDKs; no blocking calls in async paths (argon2/AES via `asyncio.to_thread`); durable JSON via atomic tmp+os.replace; auth guard is router-dependency-based (`Depends(require_user)`), never BaseHTTPMiddleware (SSE safety); RunRegistry owner-keyed `(owner_id, run_id)` + pruned; engine contracts unchanged (per-run adapter injection); `create_app(auth_enabled=True)` default with parameter-only dev override — never an env var readable in production images
- **Territory**: `backend/**`
### data-engineer
@@ -27,30 +27,30 @@ reason: "v0.2 is the MVP: FastAPI backend + engine + adapters + trust loop (back
- **Active**: true
- **Phase-specific**: false
- **Frameworks**: pydantic v2 (schemas), pytest
- **Constraints**: owns the pydantic schema contracts: dial schema (REQ-01), run state, evidence store models (stable passage IDs), citation ledger JSON schema (REQ-21); pure data + validation — no I/O in schema modules; every run persists resolved dial values (REQ-04)
- **Territory**: `backend/src/researchx/config/**`, `backend/src/researchx/evidence/**`
- **Constraints**: owns the new pydantic schema contracts: User/Session, per-user Settings, key-store envelope file, `RunState.owner_id` (serialization-compatible), report.json/evidence.json (marker normalization `p-N`→`pN`, frontend Report/Citation compatible), read-model payloads (library/tasks/suggestions/graph/profile); pure data + validation — no I/O in schema modules; every run persists resolved dial values (REQ-04 carries)
- **Territory**: `backend/src/researchx/config/**`, `backend/src/researchx/evidence/**` (+ schema review on `backend/src/researchx/auth/**` models)
### frontend-engineer
- **Domain**: frontend
- **Active**: true
- **Phase-specific**: false
- **Frameworks**: next.js (App Router), react 19, typescript (strict), tailwind, vitest, openapi-fetch, openapi-typescript
- **Constraints**: components never import the mock client or the generated client directly — only the ApiClient boundary does; RESEARCHX_API_MODE=mock|live selects the binding, mock default; no secrets or env keys in the frontend (NEXT_PUBLIC_API_BASE_URL only); zero component rewrites in the P3 swap (REQ-54/REQ-26)
- **Frameworks**: next.js (App Router), react 19, typescript (strict), tailwind, vitest, openapi-fetch, eventsource
- **Constraints**: components never import the mock or generated client directly — only the ApiClient boundary does; PLANNED interface additions (login/logout/getSession, admin hooks) documented before P1; fetch `credentials:"include"` + `X-ResearchX-Request` on all mutations; EventSource `withCredentials: true`; build-time NEXT_PUBLIC_* inlining profiles (mock dev default / live hosted with empty same-origin base); zero component rewrites in live swaps (REQ-54); no secrets or env keys in the frontend
- **Territory**: `frontend/**`
### security-engineer (phase-specific: P2)
### security-engineer (phase-specific: P1, P2, P4)
- **Domain**: security
- **Active**: false (P2 shipped — removed per phase-specific policy)
- **Active**: true while P1/P2/P4 execute; deactivated between and after P4
- **Phase-specific**: true
- **Frameworks**: httpx, pytest, STRIDE analysis
- **Constraints**: secrets resolve from env-file scopes only (OLLAMA_API_KEY, TAVILY_API_KEY, GITEA_TOKEN via .ciagent/.env.secrets) — never shell env, never logged, never in git; liveness verification and trust-loop code must not leak keys in errors or traces; SSRF guard for fetched URLs (private-address refusal); authority tiering must not phone home
- **Territory**: `backend/src/researchx/trust/**`, `backend/src/researchx/adapters/secrets*/**`
- **Removal**: deactivate after P2 ships
- **Frameworks**: argon2-cffi, cryptography (AESGCM), httpx, pytest, STRIDE analysis
- **Constraints**: KEK from env-file 'keys' scope only — never shell env, never logged, never in git, never in API responses (masked status only); AAD binds ciphertext to user_id; opaque tokens 32-byte urlsafe; SameSite=Lax + custom-header CSRF verified end-to-end; cross-user 404s provable by test; SSRF guard carries to any new fetch path; hosted exposure audit (401 on public /api, /healthz the only unauthenticated route); no `auth_enabled` env-var backdoor in production images
- **Territory**: `backend/src/researchx/auth/**` (P1/P2), `.ciagent/release/**` + hosting config review (P4)
- **Removal**: deactivate after P4 ships
### ci-lead-developer
- **Domain**: coordination
- **Active**: true
- **Phase-specific**: false
- **Frameworks**: ciagent workflows, git discipline
- **Constraints**: enforce persona territories; adapter/engine boundary review (I/O only in adapters); commit discipline with ---ci--- blocks; phase-boundary file updates only (no .ciagent/ writes during task execution); gates green before every ship
- **Territory**: `.ciagent/**`, repo root config files (README, VERSIONING, .gitignore)
- **Constraints**: enforce persona territories; adapter/engine boundary review (I/O only in adapters); commit discipline with ---ci--- blocks; phase-boundary file updates only (no .ciagent/ writes during task execution); gates green before every ship; deploy.sh two-service extension (build both images, paired rollback containers, health on /healthz + /ask, live-mode build args asserted in the smoke test); secrets only from .ciagent/.env.secrets; no deploys without owner directive (D-019) and merge-to-main halts at the owner gate (D-018)
- **Territory**: `.ciagent/**`, repo root config files (README, VERSIONING, .gitignore, Dockerfiles/deploy scripts)
+244 -25
View File
@@ -1,44 +1,263 @@
# PLAN — Milestone v0.2 Phase 5 (FINAL): Review + Audit + Ship
# PLAN — Milestone v0.3 (Hosted): Working Plan for Phases 1–4
---
phase: 5
milestone: v0.2
status: executed
phase: 0
milestone: v0.3
status: planned
must_haves:
- "Multi-persona full-milestone review: 4 P0 + 15 P1 + 15 P2 found; all 4 P0s fixed in-phase with attack-proofs (A1 tautological anchoring → real writer-quote extraction; C2 gate denominator → cited-URL scoped; C1 zombie runs → shape-validated plans + catch-all FAILED persist; S1 fetch SSRF → per-hop re-guarding); 6 cheap P1s fixed (approve-with-plan, FAILED status, length caps, probe wiring, README env-var + adapter honesty)"
- "Audit: PASS-WITH-NOTES — reconstruction PASS (all REQ-01..34 covered across v0.2.0–v0.2.4), secrets clean (zero key values in history), branch hygiene exact; critical notes fixed in-phase (REQUIREMENTS traceability un-staled, P5 PLAN written, ROADMAP P5 status, security-engineer persona deactivated per its removal note)"
- "Milestone ship: v0.2.5 tagged as the MVP release candidate — the pipeline then HALTS at the merge_to_main owner approval gate (D-018)"
- "G-009 escalation presented at the gate: the owner ratifies that live mode covers the core research loop only (G-002); remaining prototype surfaces stay mock-backed to v0.3+"
- "Engine contracts unchanged: all v0.3 work lands outside the engine — per-run adapter injection, owner-scoped storage constructors, owner_id stamping via make_run_config; no methodology/engine protocol rewrites"
- "No secret values in git, logs, traces, or API responses: model keys masked (last 4 chars) at the API boundary and encrypted at rest (AES-256-GCM, KEK from the env-file 'keys' scope), passwords argon2id-hashed and never emitted"
- "User scoping provable end to end: runs/<user_id>/<run_id>/ partitioning, owner-keyed RunRegistry, disk read-models — cross-user access 404s, pinned by test in every phase that touches the data flow"
- "Every phase ships a patch (v0.3.1..v0.3.4; P5 tags v0.3.5 = the milestone release); gates green before every ship; pipeline halts at the merge_to_main owner approval gate (D-018); no deploys without owner directive (D-019)"
- "openapi.json snapshot committed and the generated frontend client regenerated from the snapshot (never a live server) in every phase that touches routes; schema-drift check wired into the gates (G-005)"
- "Zero-key test discipline: every CI suite runs on REQ-31 fakes with auth via create_app(auth_enabled=False) or dependency_overrides; keyed/live tiers are opt-in, skipped by default"
- "Single uvicorn worker in every serving mode — the in-memory RunRegistry and session store are single-process by design"
- "Mock demoted to dev/test fixture: the code default of RESEARCHX_API_MODE stays mock; after P3 no live-mode surface delegates to MockApiClient; hosted builds inline NEXT_PUBLIC_RESEARCHX_API_MODE=live (REQ-75)"
- "G-101: startup sweep handles PLANNING-state runs — add PLANNING→CANCELLED to _LEGAL_TRANSITIONS (or document the sweep as an explicit validation bypass); pinned by a PLANNING-fixture→CANCELLED sweep test in T-1.8"
- "G-104: public-exposure hardening — password minimum length ≥12 at register + admin-create (T-1.4/T-1.5); per-IP login rate limit at the HAProxy front (stick-table on /api/auth/*, T-4.5); exposure audit asserts the throttle + uniform 401s (T-4.8)"
- "G-105: KEK-loss posture documented (T-2.2 + owner docs): loss = per-user keys unrecoverable, honest degradation to server/keyless, re-entry under a re-provisioned KEK; owner backs up the KEK off-repo; rotation tooling out of v0.3 (recorded)"
- "G-106: deploy.sh hygiene — pre-build free-disk assertion + post-swap dangling-image prune + documented host budget (2 image families × live+prev)"
- "G-107: 'staged deploy' = candidate pair built + run on the LXC host, health green, four smoke assertions host-locally; public TLS-front cutover stays a discrete owner-directive act with -prev armed"
- "G-108: P3 pressure valve (G-006 precedent) — may ship as P3a (backend artifacts + read-models + routes + suite) and P3b (standalone client + swaps + fidelity + delegation-end), tags extending on the v0.3.x line, without scope change"
- "G-109: task-inbox honesty — live-mode status is derived (answered-iff run DONE); the checkbox is display-honest in live mode (disabled/annotated; mock keeps the free toggle); persisted check state is v0.4+ (REQ-45 backlog); pinned by a live-mode component test in T-3.12"
- "G-110: cost posture stated — server-tier runs bill the owner's OLLAMA_API_KEY; the bound is the admin-managed account set; per-user counters surface server-tier usage; no hard spend cap in v0.3 (hard caps v0.4+); keyless localhost remains the zero-cost path"
personas:
- backend-engineer
- data-engineer
- frontend-engineer
- "security-engineer (phase-specific P1/P2/P4)"
- ci-lead-developer
requirements: [REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-09, REQ-10, REQ-11, REQ-12, REQ-13, REQ-14, REQ-15, REQ-16, REQ-17, REQ-18, REQ-19, REQ-20, REQ-21, REQ-22, REQ-23, REQ-24, REQ-25, REQ-26, REQ-31, REQ-32, REQ-33, REQ-34]
requirements: [REQ-69, REQ-70, REQ-71, REQ-72, REQ-73, REQ-74, REQ-75, REQ-76, REQ-77, REQ-78, REQ-79, REQ-80, REQ-81, REQ-82, REQ-83, REQ-84, REQ-85]
---
## User-Facing Surface
The MVP release candidate: the approved v0.1 prototype frontend (Ask → Choose → Plan & Run → Reading Room, all 8 cards, shell, library, settings) now backed by a real FastAPI research engine in live mode — methodology registry (Rapid Answer + Deep Report), 12-dial framework, asyncio state machine with durable pause/cancel/resume, visited-only evidence store, attack-proven trust loop (quote anchoring, SSRF-guarded liveness, citation ledger, ≥99% export gate with zero-unanchored requirement), SSE live trace, adversarial suite (14 attacks), E2E happy path on the real stack. Releases v0.2.0–v0.2.5.
The hosted multi-user product: the full approved frontend — a login screen; Ask → Choose → Plan & Run → Reading Room; the app shell with research library, project graph, task inbox, follow-up suggestions, refine dialogue, settings with masked model-key entry and resolved credential tier, avatar user menu with real profile — now live-bound end to end against the FastAPI backend with **zero live-mode delegation to the mock** (D-112), user-scoped (`runs/<user_id>/<run_id>/`), with per-user encrypted model keys resolving user → server → keyless per run, and deployed as a two-service stack (uvicorn single worker + Next.js live build) behind an SSE-safe HAProxy with TLS — real inference as the product default. Releases v0.3.0–v0.3.5.
## Happy Path (owner review script)
1. `cd backend && uv sync && uv run pytest` — 129 tests green, zero API keys (live tier: `uv run pytest -m live`, skips cleanly)
2. `cd frontend && corepack pnpm install && corepack pnpm test` — 97 tests green (90 component + 5 live-binding + 2 real-stack E2E)
3. `uv run uvicorn researchx.api.app:app --port 8000` + `NEXT_PUBLIC_RESEARCHX_API_MODE=live corepack pnpm dev` → http://localhost:3000 → Ask a question → Research this → pick Deep Report → Start research → approve the plan (3 clicks) → live SSE trace → report with anchored citations → ledger → export Markdown (citation links) / JSON (backend-schema round-trip)
4. `uv run python -m researchx.engine.demo` — full pipeline on fakes, artifacts in a temp runs dir
5. Owner decision: approve → milestone merge to main + v0.2.5 published as the MVP release; or direct changes → binding fix cycle
1. `cd backend && uv sync && uv run pytest` — all suites green with zero API keys: auth suite (`test_auth.py`), key-store tamper tests, read-model tests, restart-sweep test, engine/trust/adversarial suites under the `auth_enabled=False` harness; `uv run pytest -m live` skips cleanly.
2. `cd frontend && corepack pnpm install && corepack pnpm test` — component + live-binding suites green; typecheck + lint green.
3. Local live-mode full loop: `uv run uvicorn researchx.api.main:app --port 8000` (env-file with `RESEARCHX_KEK` + server model key) + `NEXT_PUBLIC_RESEARCHX_API_MODE=live NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 corepack pnpm dev` → http://localhost:3000 → register (first user = admin) → login → Settings: enter model key (shows masked status only), config shows resolved tier → Ask a question → Research this → pick Deep Report → Start research → approve the plan (≤3 clicks on defaults) → live SSE trace → Reading Room: hover a citation shows the stored quoted passage; ledger panel; export Markdown/JSON with real fields → Library shows the run in its auto-categorized folder; graph/tasks/suggestions surface the run's open questions; refine dialogue runs on the real planner.
4. Multi-user isolation: admin creates a second user; that user sees zero of the first user's runs, graph, tasks, or suggestions; the first user's key never affects the second user's resolved tier.
5. Hosted: `deploy.sh` → both services up + healthy; `curl https://<origin>/api/runs` → 401 unauthenticated; `curl https://<origin>/healthz` → 200; login through the public URL; SSE trace streams unbuffered through the proxy; per-user key honored end to end (public deployment per owner directive, D-019).
6. Owner decision at the P5 gate: approve → v0.3.5 = milestone release, merge to main; or direct changes → binding fix cycle.
## UX Acceptance Criteria
- ≤3 clicks from cold start to initiated research, validated on the real stack (REQ-17)
- Every trust claim attack-proven: hallucinated citations fail the export gate; fabricated quotes on real passage IDs flag unanchored; SSRF redirects die; pause/resume/cancel survive the loop
- Honest scope: live mode = core research loop (G-002); library/settings/suggestions/refine stay mock-backed until v0.3+ — ratified at this gate (G-009)
- No secret value in any artifact, trace, or log; zero-key test discipline throughout
- The ≤3-click research path (Ask → Choose → Run on defaults) is preserved for an authenticated user; login is a once-per-session step (14-day sliding) adding zero clicks to the path.
- Reading Room parity with the approved mock: page-anchored citations whose hover shows the exact stored quoted passage; a real ledger panel; exports with real entry data — no fabricated fields anywhere.
- Honesty in live mode: resolved credential tier visible (user|server|keyless), key-store-disabled stated honestly, no synthetic suggestions, honest empty states for fresh users.
- Suggestions click-fills-the-box (never auto-starts); refine is opt-in and model-driven.
- Keys masked everywhere: no secret value is ever rendered, logged, or exported.
- Cross-user isolation is total and invisible: no surface leaks another user's runs, questions, or key status.
## What this phase did
## Planned ApiClient Extensions (C3-18 — documented before P1; this list is exhaustive)
1. **Review** (multi-persona, probe-verified): 4 P0 + 15 P1 + 15 P2. The 4 P0s broke the milestone's flagship guarantees and were all fixed with attack-proofs: quote anchoring extracted the stored passage's own quote (always 1.0) instead of the writer's text; the export-gate denominator counted unprobed visited pages as dead; malformed planner JSON left zombie runs; fetch adapters followed redirects without re-guarding. Six cheap P1s also fixed.
2. **Audit**: PASS-WITH-NOTES — reconstruction, secrets sweep (clean), branch/tag hygiene exact; both critical notes (stale traceability rows, missing P5 plan) fixed in-phase.
3. **Ship (this commit sequence)**: PR #14 (phase/05 → milestone), squash-merge, tag v0.2.5 — the MVP release candidate, release with the full milestone summary. Per D-018 and the merge_to_main escalation hook, the milestone merge to main does NOT proceed autonomously — the pipeline halts for the recorded owner decision, with G-009 (live-mode scope) presented at the same gate.
- `login(email, password): Promise<UserProfile>` — POST /api/auth/login (sets the session cookie)
- `logout(): Promise<void>` — POST /api/auth/logout
- `getSession(): Promise<UserProfile | null>` — GET /api/auth/me (401 → null)
- Admin hooks (API-first in v0.3; no dedicated admin screen): `listUsers()`, `createUser(input)`, `deactivateUser(id)` — GET /api/admin/users, POST /api/admin/users, POST /api/admin/users/{id}/deactivate
- MockApiClient gains matching no-op/local implementations (standalone mock preserved — REQ-31/REQ-54); live implements against the routes. The v0.2 pause/resume extensions (G-003) carry unchanged. No other interface changes are planned for v0.3.
## Scope Guard
## Phase 1 — Auth + Sessions + User Scoping (ships v0.3.1)
Milestone merge to main, v0.2.5 publication, and any deploy wait on the owner approval gate (D-018/G-009). The pipeline does not proceed autonomously past this commit.
**Personas** (lead-developer decomposition by territory): backend-engineer (lead — auth package, storage partitioning, engine/app wiring, sweep/pruning), data-engineer (auth + state pydantic contracts), security-engineer (phase-specific — auth adversarial suite + STRIDE), frontend-engineer (session surface, snapshot gate), ci-lead-developer (ship).
**Requirements covered**: REQ-69, REQ-70, REQ-71
**Task waves**:
*Wave 1 — contracts + stores (parallel)*
- **T-1.1 (backend-engineer)**: Data root + user store — `researchx.config.paths.data_root()` (RESEARCHX_DATA_DIR → `backend/var/researchx` dev; D-107); `auth/users.py`: User model (user_id = 8-char slug, validation `^[a-z0-9]{8}$`, D-105; email; argon2id via argon2-cffi `PasswordHasher()` defaults, hash/verify/rehash all in `asyncio.to_thread`, D-102; display_name; role; active; created_at), `users.json` at the data root, atomic rewrite (D-017 idiom). Pyproject gains `argon2-cffi>=23.1,<26` (no python-multipart).
- **T-1.2 (backend-engineer)**: Session store — `auth/sessions.py`: opaque `token_urlsafe(32)` tokens, in-process dict + durable `sessions.json` (atomic), `Session {token_hash, user_id, created_at, last_seen, expires_at}`, 14-day sliding expiry via `touch()` with >60s persist throttle, token rotation at login, immediate revocation (logout deletes the presented token; deactivation deletes all the user's tokens) (D-101).
- **T-1.3 (data-engineer)**: Auth + state contracts — pydantic User/Session schemas, auth route request/response models, `RunState.owner_id: str | None = None` (serialization-compatible, no migration per D-106); old state files must deserialize.
*Wave 2 — enforcement (depends W1)*
- **T-1.4 (backend-engineer)**: Guard + auth routes + CORS — `auth/guard.py` `require_user` dependency (session resolve → 401; `X-ResearchX-Request` header required on non-GET → 403; inactive user → 401; sliding touch); `auth/routes.py` register/login/logout/me with httpOnly `researchx_session` cookie (SameSite=Lax, path=/, Secure hosted-only flag; no Domain attribute); uniform 401 "invalid email or password"; register open only while the user store is empty — first user `role="admin"`, then 403 (no await between check and insert except the argon2 hash; single worker = race-free, D-104); public router (`/api/auth/*`, `/healthz` outside /api per D-103) + protected router (everything else) via `APIRouter(dependencies=[Depends(require_user)])` — **router dependencies, not middleware** (C3-01; no BaseHTTPMiddleware near SSE); CORS: `allow_credentials=True` + explicit localhost origins, ordered so preflight OPTIONS answers without 401 (C3-04); login/register POSTs also require the CSRF header (C3-05).
- **T-1.5 (backend-engineer)**: Admin routes — POST /api/admin/users (admin-guarded create with admin-set initial password), GET /api/admin/users (list), deactivate (kills all sessions; data retained) — REQ-70.
- **T-1.6 (backend-engineer)**: Storage partitioning — `LocalStorageAdapter(base_dir, owner_id)` with **required** owner_id (validated), `run_dir → base_dir/runs/<owner_id>/<run_id>/`, + `list_run_ids()` (read-models scan later); `StorageAdapter` protocol gains `append_run_artifact` (the actor already calls it); `read_run_artifact` error message embeds the owner path; consolidate the duplicate run-id generators (P-14). Protocol otherwise byte-for-byte unchanged (D-106).
*Wave 3 — engine/app wiring (depends W2)*
- **T-1.7 (backend-engineer)**: Owner-scoped app wiring — `make_run_config(..., owner_id)` stamps `state.owner_id`; RunActor untouched (engine contracts unchanged); `RunRegistry` keyed `(owner_id, run_id)` — cross-owner access 404; routes construct the owner-scoped storage per request from the auth dependency; `create_app(*, auth_enabled=True)` default with **parameter-only** dev override resolving a synthetic dev user (`user_id="dev00000"`, role admin — C3-16, never an env var readable in production); `dev_bridge.py` stays explicitly `auth_enabled=False` (P-5); `engine/demo.py` owner arg (P-9); remove the dead duplicate `return EventSourceResponse` (app.py:331, P-11); mechanical `owner_id` constructor args across the 8 test files (RESEARCH §5 touchpoint list).
- **T-1.8 (backend-engineer)**: Startup sweep + registry pruning — on `create_app` startup, rewrite every non-terminal `state.json` under the data root as CANCELLED (D-115; legal from every non-terminal state; in-flight actors die with the process); registry drops terminal `(actor, bus)` entries once no live SSE subscriber (refcount in the events route — P-6); events-404-after-prune/restart documented (trace persists on disk).
*Wave 4 — adversarial + frontend + snapshot (depends W3)*
- **T-1.9 (security-engineer + backend-engineer)**: Auth suite — `backend/tests/test_auth.py` with `auth_enabled=True` (RESEARCH §11): first-user-admin (+403 register after), wrong-password 401 uniform, logout revocation, 401 on all protected routes, CSRF header rejection, cross-user 404 (all run endpoints), admin create/deactivate (deactivation kills sessions), session expiry, cookie flags, no password-hash material in any response; pins P-5 and the authorization half of P-8.
- **T-1.10 (frontend-engineer)**: Session surface — ApiClient extensions per C3-18 (mock no-ops; live against routes); login screen (email + password, error states); live-mode route guard redirecting unauthenticated users to /login; user menu shows the real profile + logout; fetch discipline: `credentials:"include"` + `X-ResearchX-Request` header on every mutation (C3-05); dev docs pin same-hostname origins (`localhost` both sides — port is not part of same-site computation).
- **T-1.11 (frontend-engineer)**: Snapshot regen gate — export + commit `backend/openapi.json` (auth/admin routes included), regenerate `frontend/src/lib/api/generated/` from the snapshot only, wire the schema-drift check into the gates, operation-ids stable (G-005).
*Wave 5 — ship*
- **T-1.12 (ci-lead-developer)**: Phase ship — all gates green (backend ruff/mypy/pytest incl. `test_auth.py`; frontend lint/typecheck/vitest; drift gate), tag **v0.3.1** + release; REQUIREMENTS/ROADMAP traceability rows updated.
**Must-haves (verification-oriented)**:
- Unauthenticated access to any `/api` route except register/login → 401; register 403s once the user store is non-empty — pinned by `test_auth.py`.
- First registered user is admin; all subsequent accounts admin-created; deactivation kills sessions and retains data.
- No user can read or act on another user's data: owner-keyed registry → cross-owner 404 on run GET/lifecycle/report/ledger/events — pinned by test.
- Storage layout is `runs/<user_id>/<run_id>/`; `owner_id` recorded in every persisted run state.
- Sessions are server-side with httpOnly SameSite=Lax cookies, immediate revocation, sliding expiry; CSRF header enforced on non-GET (EventSource GET exempt).
- Engine contracts unchanged; existing zero-key suites green with only mechanical `owner_id` constructor changes.
- `openapi.json` snapshot committed; generated client regenerated from it; drift gate fails on intentional drift (proof).
- No run survives a restart in a non-terminal state (sweep test); terminal registry entries prune (leak test).
- No password hash or key material in any API response or log.
**Pitfall closures (RESEARCH §12)**: **P-4** (CORS `allow_credentials` + ordering), **P-5** (dev_bridge explicitly auth-off, pinned by the suite), **P-6** (registry pruning), **P-7** (owner_id + partitioning), **P-9** (demo.py owner arg), **P-10** (layout test re-pinned to the partitioned path), **P-11** (dead code removed), **P-14** (run-id generators consolidated). P-8's authorization half (owner-keyed 404s) closes here; its registry-independence half closes in P3.
**Success criteria (testable)**:
- `test_auth.py` green covering the full §11 list; two-user isolation test proves zero visibility across every run endpoint.
- Register → admin; second register → 403; admin creates + deactivates (sessions die) — all route-tested.
- Non-GET without the header → 403; with header + session → 2xx.
- Disk layout test proves `runs/<owner>/<run_id>/`; restart-sweep test proves non-terminal → CANCELLED across an app restart; pruning test proves terminal entries drop with zero SSE subscribers.
- Drift gate: intentional schema change fails the gate; regen green.
- Gates green; patch v0.3.1 shipped.
## Phase 2 — Per-User Settings + Model Keys (ships v0.3.2)
**Personas**: backend-engineer (lead — key store, settings store, model factory, config rework, production entrypoint), data-engineer (settings + key-envelope schemas), security-engineer (phase-specific — key-store adversarial suite), frontend-engineer (settings live-binding, snapshot), ci-lead-developer (ship).
**Requirements covered**: REQ-72, REQ-73, REQ-74
**Task waves**:
*Wave 1 — contracts*
- **T-2.1 (data-engineer)**: Settings + key-envelope schemas — per-user Settings payload (model names, methodology defaults + resolved dials, appearance slot, masked key status, storage path display-only — D-111); key-store envelope `{v, alg, updated_at, entries: {OLLAMA_API_KEY: {nonce, ct, updated_at}}}` (D-108/D-109); KEK decode rules (urlsafe-b64 or hex, exactly 32 bytes; decode/length errors are typed AdapterErrors).
*Wave 2 — stores (depends T-2.1)*
- **T-2.2 (backend-engineer)**: Key store — `auth/key_store.py`: AES-256-GCM via `cryptography` AESGCM (pyproject gains `cryptography>=43`); KEK = `RESEARCHX_KEK` from the env-file **'keys'** scope (`config.json` secrets.scopes gained `"keys": ["RESEARCHX_KEK"]` in P0; provisioned into `.ciagent/.env.secrets` only — never git); AAD = `user_id` (a copied file under another user fails `InvalidTag`); fresh `os.urandom(12)` nonce per write (never reused under the KEK); `keys/<user_id>.json`, atomic, 0600; crypto in `asyncio.to_thread`; honest degradation: no KEK → disabled (PUT rejected with `{"key_store": "disabled"}`), corrupt entries surface as errors, never a silent tier drop (C3-06). KEK change requires process restart (documented).
- **T-2.3 (backend-engineer)**: Settings persistence — `settings/<user_id>.json` store, atomic rewrite, defaults for fresh users, GET/PUT payload shapes (D-111).
*Wave 3 — routes + factory (depends W2)*
- **T-2.4 (backend-engineer)**: Settings + key routes — GET/PUT `/api/config/settings`; model-key PUT accepts a raw key (scope = OLLAMA_API_KEY only, D-109) → encrypted at rest; every response returns configured + masked (last 4) only — the raw key never appears in any response, log, or trace (REQ-73).
- **T-2.5 (backend-engineer)**: Per-run model factory — `adapters/model_factory.py`: `ModelAdapterFactory(secrets, key_store, defaults).resolve(user, settings) → (adapter, tier)` resolving user → server → keyless (REQ-74); `OllamaModelAdapter` constructor gains optional `api_key` (precedence over secrets resolve; secrets path back-compat); **one adapter instance per run** — the `_active_base` fallback mutation is per-instance, never shared (P-2 closure); `create_run` resolves + injects (engine contracts unchanged); state records `owner_id` + `credential_tier`.
- **T-2.6 (backend-engineer)**: Config status rework — GET `/api/config` resolves the **current user's** tier via the factory without creating a run (kills the `runs._actors` private read — P-1 closure; no cross-user probe leak, no zero-run breakage); probe cached in-process keyed `(user_id, tier, key_fingerprint)` with short TTL, running against the resolved tier; per-user cost counters owner-keyed (P-13 closure; G-007 carries); `resolved_tier` + `key_store: enabled|disabled` surfaced.
*Wave 4 — production + adversarial + frontend (depends W3)*
- **T-2.7 (backend-engineer)**: Production entrypoint — new `researchx.api.main` wiring `create_app(data_root=RESEARCHX_DATA_DIR, factory=<live factory from env-file secrets>, auth_enabled=True)` (P-3 backend closure — the `app.py:359` module global serving fakes + cwd storage leaves production); `app.py` stays the test factory; `main.py` never passes `auth_enabled=False`.
- **T-2.8 (security-engineer + backend-engineer)**: Key-store adversarial suite — tamper tests: AAD binding (key file copied under another user_id → `InvalidTag` surfaced), nonce uniqueness across writes, masked-only responses (no raw key material in response/log/trace), disabled-honesty without KEK, corrupt-entry error surfacing; opt-in keyed tier (skipped by default; zero-key CI unchanged) proving a keyed user gets real inference end to end through the REQ-74 chain.
- **T-2.9 (frontend-engineer)**: Settings live-binding — `GeneratedApiClient` getSettings/saveSettings → `/api/config/settings`; masked key entry (password-type input → PUT; masked status display; never echoed); getConnectionStatus maps GET `/api/config` (resolved tier user|server|keyless, key_store status, probe, per-user counters); storage directory display-only in live/hosted mode (D-111 — the free-path picker is the local single-user profile only); mock parity preserved standalone.
- **T-2.10 (frontend-engineer)**: Snapshot regen — settings/key routes change `openapi.json` → snapshot committed, client regenerated, drift gate green (G-005).
*Wave 5 — ship*
- **T-2.11 (ci-lead-developer)**: Phase ship — gates green; tag **v0.3.2** + release; traceability updated.
**Must-haves**:
- Keys never appear in full in any API response, log, or trace — masked only, pinned by test.
- Key store: AES-256-GCM, AAD=user_id, fresh nonce per write, 0600, atomic — tamper-proof pinned by test.
- Without a KEK: key store disabled honestly; resolution proceeds server/keyless; never a silent tier drop.
- Per-run credential chain user → server → keyless; probe runs against the resolved tier; config status surfaces the current user's tier without creating a run and without reading private registry state.
- Shared-adapter fallback bug dead: one adapter per run; `_active_base` mutation can never cross users.
- Cost counters owner-keyed.
- Engine contracts unchanged; zero-key suites green (fakes, `auth_enabled=False`).
- Production entrypoint live-wired; the module-global fake app serves nothing.
**Pitfall closures**: **P-1** (config_status → factory-resolved probe), **P-2** (per-run factory kills the shared `_active_base` mutation), **P-13** (owner-keyed cost counters), **P-3 backend half** (`researchx.api.main`; the hosting half closes in P4).
**Success criteria**:
- Opt-in keyed tier: a keyed user gets fully real inference end to end; `resolved_tier="user"` in config status.
- Zero-key CI: full suite green with no KEK configured — key store reports disabled, tier falls to server/keyless honestly.
- Tamper suite green: AAD tamper → InvalidTag surfaced; nonces unique; masked-only responses.
- Config status correct with zero runs and per-user isolated (no probe leak).
- Drift gate green; gates green; patch v0.3.2 shipped.
## Phase 3 — Live Surfaces, user-scoped (ships v0.3.3)
**Personas**: backend-engineer (lead — artifacts, read-models, all new routes), data-engineer (artifact + read-model payload schemas), frontend-engineer (standalone client, all surface swaps, Reading Room fidelity), ci-lead-developer (ship). Security-engineer inactive this phase (no new secret handling — roster reason).
**Requirements covered**: REQ-76, REQ-77, REQ-78, REQ-79, REQ-80, REQ-81, REQ-82 (+ REQ-75 delegation-end half: after this phase no live-mode surface delegates to the mock)
**Task waves**:
*Wave 1 — artifacts + read-models (parallel)*
- **T-3.1 (backend-engineer + data-engineer)**: report.json + evidence.json at DONE — persisted inside `_verify_phase` right after ledger/checklist (EvidenceStore is GC'd with the actor — this is the only write point; D-113): `report.json {v, run_id, owner_id, title, question, methodology_slug, generated_at, sections:[{heading, body}], citations:[{passage_id, url, title, quote, health, authority_tier}]}`, `evidence.json {v, run_id, passages:[{passage_id, url, title, quote, text}]}`; **marker normalization at the persistence boundary only**: section bodies `[p-N]` → `[^pN]`, ids `p-N` → `pN` (the Reading Room parses with zero frontend changes); `report.md` keeps raw `[p-N]` — trust-loop regexes untouched (C3-10).
- **T-3.2 (backend-engineer + data-engineer)**: Read-models — `api/read_models.py` scans `runs/<owner_id>/` loading `state.json` (+ report.json/ledger.json where needed); skips dotfiles/tmp defensively (D-115); payload schemas shaped to the existing frontend types: library folders (auto-categorization by methodology + deterministic topic tokens from plan sub-questions) + ReportSummary, ReportConnection, ProjectGraph nodes/edges, InboxItem `{id: "<run_id>#<idx>", question, sourceRunId, status}`, SuggestedQuery.
- **T-3.3 (backend-engineer)**: Enriched report endpoint — GET `/api/runs/{id}/report` → `{run_id, markdown, report, evidence}` (markdown retained, back-compat); **disk-read, registry-independent** (P-8 full closure); graceful markdown-only fallback for pre-artifact DONE runs (old artifacts disposable per D-106, but never a 500); ledger endpoint likewise disk-read.
*Wave 2 — routes (depends W1)*
- **T-3.4 (backend-engineer)**: Library + connections + graph — GET `/api/library?sort=recent|title` (folders + ReportSummary[] resolving to real runs); GET `/api/library/connections` (shared-source edges ≥1 ledger-URL overlap + topical edges); GET `/api/graph` (same edges in ProjectGraph shape) — REQ-76/77, D-114.
- **T-3.5 (backend-engineer)**: Tasks + suggestions — GET `/api/tasks` (open questions = plan sub-questions, answered-iff run reached DONE); GET `/api/suggestions` (open items joined with source run titles; no synthetic suggestions; empty state for fresh users) — REQ-78/79, D-114.
- **T-3.6 (backend-engineer)**: Refine + profile — POST `/api/refine/questions {draft}` → 2–3 planner clarifying questions; POST `/api/refine/compose {draft, answers}` → composed refined question + suggested context; planner calls ride the **same per-user credential chain** (P2 factory), honest 503 for a keyless user with no local endpoint — never a hang; GET/PUT `/api/profile` (display_name, email; no deletion) — REQ-80/81. All owner-scoped via the auth dependency; non-GET ⇒ CSRF header applies.
*Wave 3 — frontend standalone (depends routes + snapshot)*
- **T-3.7 (frontend-engineer)**: Snapshot regen — all new routes into the committed `openapi.json` snapshot; client regenerated; drift gate green (G-005).
- **T-3.8 (frontend-engineer)**: GeneratedApiClient standalone — every ApiClient method overridden against real endpoints; `extends MockApiClient` **dropped** (a standalone class implementing ApiClient — D-112); request wrapper: `credentials:"include"` + `X-ResearchX-Request` on every mutation; `EventSource` constructed with `{withCredentials: true}`; mock remains a standalone dev/test fixture.
- **T-3.9 (frontend-engineer)**: Surface swaps — getReportLibrary, getReportConnections, getProjectGraph, getTaskInbox, getSuggestedQueries, getRefinementQuestions, composeRefinedQuestion, getUserProfile, plus the P2 settings/config methods — all live, zero component rewrites (REQ-54); suggestions click-fills never auto-starts; the refine drawer drives the real exchange.
- **T-3.10 (frontend-engineer)**: Reading Room fidelity + honest exports — getReport maps report.json → Report (sections + citations with **real quotes**; tier map official→1, reference→2, else 3); getLedger real entries; exportReport built from real entry data (kills the fabricated `claims_supported: 1` and empty quotes — P-12 closure, REQ-82); hover shows the stored quoted passage — parity with the mock Reading Room v0 (REQ-82); exports round-trip through the backend-schema gate (REQ-34 carries) with citation links intact.
*Wave 4 — tests + ship (depends W3)*
- **T-3.11 (backend-engineer + data-engineer)**: Read-model test suite — `backend/tests/test_read_models.py`: a fixture disk tree (two users; DONE, CANCELLED, in-flight, FAILED runs) asserting library folder categorization + recency/title sorting; shared-source edges (≥1 ledger URL overlap) + topical edges; graph node/edge shape; tasks answered-iff DONE; suggestions = open items joined with run titles, empty for a fresh user, never synthetic; dotfile/tmp skip; **registry-independence test**: all read endpoints correct against a fresh `create_app` over an existing data root with zero in-memory actors (P-8 full-closure proof); artifact tests: report.json/evidence.json written at DONE with normalized markers (`[^pN]` in report.json, raw `[p-N]` in report.md), markdown-only fallback for pre-artifact DONE runs (never a 500).
- **T-3.12 (frontend-engineer)**: Live-surface regression suite — component tests run unchanged against the live binding (zero component rewrites proven, REQ-54); ≤3-click path pinned green in live mode; suggestions click-fills-the-box never auto-starts; refine exchange drives the real endpoint on fakes; drift gate green.
- **T-3.13 (ci-lead-developer)**: Phase ship — all gates green; tag **v0.3.3** + release; traceability rows updated.
**Must-haves**:
- Every prototype surface (library, connections, graph, tasks, suggestions, refine, profile, settings, Reading Room) works against real user data in live mode — `GeneratedApiClient` is standalone, `extends MockApiClient` is gone (D-112), and no live-mode method delegates to the mock.
- Read-models read disk artifacts only, never RunRegistry: every read endpoint is correct after a restart with zero in-memory state; non-terminal runs post-sweep appear as CANCELLED, terminal runs stay readable.
- Reading Room parity with the mock: structured sections, page-anchored citations whose hover shows the stored quoted passage, ledger panel from real ledger.json; exports carry real quotes/claims/health/tier — no fabricated fields.
- Engine persists report.json + evidence.json at DONE inside `_verify_phase`; `report.md` marker syntax untouched (trust-loop regexes unchanged).
- All new routes owner-scoped via the auth dependency — cross-user 404s pinned; non-GET routes CSRF-header-guarded (refine POSTs included).
- ≤3-click path green; suggestions never auto-start; honest empty states; no synthetic suggestions.
- Snapshot committed, client regenerated from the snapshot only, drift gate green (G-005).
- Zero-key discipline intact: refine tests on REQ-31 fakes; keyless refine surfaces an honest 503, never a hang.
**Pitfall closures**: **P-8 full closure** (registry-independent disk reads — auth half closed in P1, read half here, pinned by the restart test), **P-12** (export fabrication killed — real quotes/claims from report.json/evidence.json). Marker-normalization hazard (§6) closed by construction: normalization at the persistence boundary only, `report.md` raw form pinned by test.
**Success criteria**:
- Read-model suite green over the multi-user fixture tree; fresh-app-over-existing-data-root test proves every read endpoint without the registry.
- Live mode: all surfaces live-bound, component tests unchanged, 3-click green, no delegation to mock anywhere in the live client.
- Export JSON payloads contain real quotes, real claims_supported, real health/tier — the fabricated `claims_supported: 1` / empty quotes are gone and a test pins their absence.
- Fresh user: empty library, empty tasks, empty suggestions — honest, no synthetic content.
- Drift gate green; gates green; patch v0.3.3 shipped.
## Phase 4 — Hosting + Real-Inference Default (ships v0.3.4)
**Personas**: ci-lead-developer (lead — deploy.sh, HAProxy config, hosting per its repo-root territory), backend-engineer (Dockerfile, production wiring completion, health endpoint), frontend-engineer (live build profile, build args), security-engineer (phase-specific — hosted exposure audit through the public URL).
**Requirements covered**: REQ-75, REQ-83, REQ-84, REQ-85
**Task waves**:
*Wave 1 — container + production wiring*
- **T-4.1 (backend-engineer)**: Backend Dockerfile — `python:3.12-slim` + uv from the ghcr image; `uv sync --frozen --no-dev`; `useradd --system --uid 10001 researchx`; `mkdir -p /var/lib/researchx` + chown; `USER researchx`; `CMD ["uvicorn", "researchx.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--proxy-headers"]` (REQ-83, C3-13). Secrets are runtime-only via `--env-file` (`RESEARCHX_KEK`, `OLLAMA_API_KEY`, `OLLAMA_BASE_URL`, `TAVILY_API_KEY`) — never baked into the image, never in git.
- **T-4.2 (backend-engineer)**: Production wiring completion — `researchx.api.main` verified end to end: `data_root=RESEARCHX_DATA_DIR` (container default `/var/lib/researchx`), live model factory from env-file secrets, `auth_enabled=True` hardwired — **P-3 full closure**: no production path can serve FakeModelAdapter or cwd-based storage; parameter-only auth override confirmed absent from the image (no `RESEARCHX_AUTH`-style env readable anywhere); uvicorn graceful-shutdown behavior documented (in-flight runs exercise `_persist_cancelled` on SIGTERM; `docker rm -f` SIGKILLs — the P1 startup sweep is the safety net).
*Wave 2 — frontend live build profile*
- **T-4.3 (frontend-engineer)**: Live build profile — frontend Dockerfile build stage gains `ARG`/`ENV` before `pnpm build`: `NEXT_PUBLIC_RESEARCHX_API_MODE=live` + `NEXT_PUBLIC_API_BASE_URL=""` (empty = same-origin through HAProxy's `/api` path route); `baseUrl()` treats `""` as same-origin (openapi-fetch relative base, EventSource relative URL); build asserts the empty base (a non-empty base would break same-origin cookies); dev live-mode docs pin `http://localhost:8000` on both sides (hostname rule, C3-15).
*Wave 3 — deploy + proxy*
- **T-4.4 (ci-lead-developer)**: deploy.sh two-service extension — rsync now includes `backend/`; both images built on the host; plain docker-run pairs on the existing LXC idiom (no compose dependency): `researchx-net` bridge network; `researchx-api` backend (env-file, data volume bind, `-p 127.0.0.1:8000:8000` with host-side HAProxy, `--restart` policy); `researchx` frontend (`-p 3000:3000`, live build args); paired rollback containers `researchx-api-prev` + `researchx-prev` both retained; data volume pre-created + `chown 10001` host-side (belt-and-braces against EACCES); health check on BOTH `/healthz` (backend) and `/ask` (frontend) before the swap; idempotent re-run.
- **T-4.5 (ci-lead-developer, security-engineer review)**: HAProxy front config — documented + idempotent update to the host's existing HAProxy 2.8 TLS frontend: `acl is_api path_beg /api`, `acl is_health path /healthz` → `backend_api`; `default_backend frontend_next`; `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`; **NO `option http-buffer-response`, NO compression on the API backend** (gzip buffers text/event-stream); `backend frontend_next`: `timeout server 300s`, `server next1 127.0.0.1:3000 check` (REQ-84, C3-14); `timeout client` exceeds the sse-starlette 15s ping interval; the config file is committed as documentation; application to the live host only on owner deploy directive (D-019).
- **T-4.6 (backend-engineer)**: `/healthz` verification — unauthenticated `GET /healthz → 200 {"status":"ok"}` (public router from P1, outside `/api` per D-103) proven through the proxy path route.
*Wave 4 — smoke + exposure audit + ship*
- **T-4.7 (ci-lead-developer)**: Hosted smoke assertions — scripted against a staged instance: (1) unauthenticated `curl` to public `/api/runs` → **401**; (2) `/healthz` → **200**; (3) **live-mode marker**: authenticated `/api/config` resolves a real tier and the smoke asserts the build inlined live mode (empty base + live mode, not mock); (4) **SSE-through-proxy smoke**: an authenticated EventSource against the public origin streams a full run's events unbuffered end to end — no buffering, no mid-stream cut, 15s pings hold the connection through `timeout server 3600s` (REQ-84/85).
- **T-4.8 (security-engineer)**: Hosted exposure audit — through the public URL: every `/api` route 401s unauthenticated (401-on-public-/api pinned); `/healthz` is the ONLY unauthenticated route; no `auth_enabled` backdoor in the production image (parameter-only, `main.py` never passes False — verified); cookie `Secure` flag set on the TLS-fronted host; secrets sweep over the built image layers and deploy artifacts (zero key values anywhere); STRIDE pass on the proxy surface.
- **T-4.9 (ci-lead-developer)**: Phase ship — all gates green; tag **v0.3.4** + release; traceability updated; deployment to the live host staged only on owner directive (D-019) — the smoke suite is written to run against a staged deploy.
**Must-haves**:
- One-command deploy brings up both services; paired rollback containers exist for BOTH services (REQ-83).
- Backend container: non-root uid 10001, single uvicorn worker (`--workers 1` mandatory — in-memory registry + session store), secrets runtime-only via `--env-file`, data volume writable by uid 10001.
- The hosted instance runs real inference in live mode by default (build-time inlined; REQ-75) with per-user keys honored through the full chain — while the code default stays mock (zero-key dev/CI preserved, REQ-31/D-112).
- SSE streams unbuffered through the proxy: no http-buffer-response, no API compression, timeout server/tunnel 3600s, pings unbroken — verified end to end.
- Unauthenticated public `/api` → 401 on every route; `/healthz` 200 the only unauthenticated route (REQ-85).
- No secret values baked into images, committed, or logged; volume chown 10001.
- HAProxy changes documented + idempotent; applied only on owner directive (D-019).
**Pitfall closures**: **P-3 full closure** (hosting half — the container serves `researchx.api.main` with the live factory; nothing can host fakes). RESEARCH §8/§9 hazards encoded and tested: volume-ownership EACCES (chown 10001 + smoke write), SSE-compression hazard (explicitly absent from the API backend config, asserted in review), ping keepalive (SSE smoke holds ≥ several ping intervals), SIGKILL-on-redeploy (startup sweep as the net, covered by the P1 sweep test).
**Success criteria**:
- Both images build on the host via the extended deploy.sh; staged smoke suite passes all four assertions (401 / healthz 200 / live-mode marker / SSE unbuffered).
- A keyed user on the staged instance gets fully real inference end to end through the proxy (`resolved_tier="user"` visible in the authenticated UI).
- Image-layer secrets sweep clean; no auth-disable path exists in the production image.
- Gates green; patch v0.3.4 shipped.
## Phase 5 — Final Review + Ship (FINAL — not planned in detail here)
Per ROADMAP: multi-persona review of the full milestone diff (REQ-69..85), audit (reconstruction, secrets sweep, branch/tag hygiene), then tag **v0.3.5 = the milestone release**. The pipeline HALTS at the merge_to_main owner approval gate (D-018 carries); deployment to research.coreci.dev proceeds only on owner directive (D-019 carries). The security-engineer's removal note (deactivate after P4) executes at P4 ship; the P5 review runs the standard full multi-persona pass. Detailed planning lands as the P5 PLAN when P4 completes, per the v0.2 precedent.
## Cross-Phase Notes
- **Versioning**: patches v0.3.1 (P1) → v0.3.2 (P2) → v0.3.3 (P3) → v0.3.4 (P4) → v0.3.5 (P5, the milestone release); tags on the v0.3.x line; MAJOR stays 0.
- **Carrying gates**: G-005 snapshot discipline applies in every route-touching phase (P1, P2, P3); G-007 cost counters survive as owner-keyed (P-13, P2); G-008 DDG fixture discipline carries untouched; D-018/D-019 halt + deploy gates bind at P4/P5.
- **REQ coverage assert**: each of REQ-69..85 maps to ≥1 test or contract proof in its phase (auth suite, key-store tamper suite, read-model suite, hosted smoke) — the audit reconstructs the milestone from these.
- **Interface additions**: the C3-18 list is exhaustive; any other ApiClient change requires a PLAN amendment before implementation (the G-002 lesson, restated).
- **Pitfall ledger (C3-19)**: P-1, P-2, P-13 close in P2; P-4, P-5, P-6, P-7, P-9, P-10, P-11, P-14 + P-8 (auth half) close in P1; P-8 (full) + P-12 close in P3; P-3 closes across P2 (backend half) and P4 (hosting half). All fourteen verified closed by v0.3.4.
- **Engine contracts unchanged throughout**: the only engine-file touches in the milestone are additive — `RunState.owner_id` stamping (P1) and the `_verify_phase` artifact writes (P3, D-113); no methodology, state-machine, or protocol rewrites anywhere.
+19 -8
View File
@@ -6,9 +6,10 @@ ResearchX (working name; rename deferred — naming collision audit on file, see
## Core Value
A deep-research studio where you bring your own model, storage, and memory; choose a research methodology before a single query fires; and every claim in the final report is anchored to a visited, verified, quotable source. Simple enough to run in three clicks, transparent enough to audit line by line.
## Objective (two-milestone plan, owner-directed 2026-09-27)
1. **Milestone v0.1 — Prototype**: UI/UX only, mock data, the production Next.js frontend with all data behind a typed `ApiClient` interface backed by `MockApiClient`. Surfaces: Ask, Choose (all 8 methodology cards), Plan & Run, Reading Room v0, v2 surface mockups (project graph, task inbox). **Ships only after owner approval — no MVP work begins until approved.**
2. **Milestone v0.2 — MVP**: gated on prototype approval. FastAPI backend, custom async engine, adapter layer, trust loop, and integration (MockApiClient swapped for the OpenAPI-generated client).
## Objective (three-milestone plan, owner-directed; v0.3 scope directed 2026-09-29)
1. **Milestone v0.1 — Prototype** (COMPLETE, approved 2026-09-27, v0.1.8): UI/UX with mock data — production Next.js frontend behind a typed `ApiClient`/`MockApiClient` boundary.
2. **Milestone v0.2 — MVP** (COMPLETE, approved 2026-09-27, v0.2.5): FastAPI backend, async engine, adapter layer, trust loop, OpenAPI client swap. Live mode = core research loop only (G-002/G-009 ratified).
3. **Milestone v0.3 — Hosted** (owner-directed 2026-09-29): the hosted multi-user product — auth + sessions (user/pass, admin-managed accounts, OAuth deferred), per-user settings incl. model API keys (encrypted at rest; resolution chain user key → server key → keyless localhost), all prototype surfaces live-bound and user-scoped (library, settings persistence, suggestions, refine, graph/inbox, profile), backend hosting for the LXC deployment (SSE-safe), and real inference as the product default (mock demoted to dev/test fixture). The remaining 6 methodologies stay v0.4+ scope.
## Requirements (validated)
1. Five pluggable adapter slots: model (OpenAI-compatible, planner/writer split), storage (local FS), search (Tavily default, DuckDuckGo keyless fallback), memory (off — stubbed), secrets (env — OpenBao later)
@@ -28,16 +29,18 @@ A deep-research studio where you bring your own model, storage, and memory; choo
- Memory scopes (semantic caching), S3/git storage adapters, Exa/Perplexity/Google search, OpenBao secrets, Docker packaging
## Constraints
- Prototype must be approved before MVP work begins (owner directive)
- ~~Prototype must be approved before MVP work begins~~ (v0.1 approved 2026-09-27; v0.2 approved 2026-09-27)
- Versioning starts at v0.1; MAJOR remains 0 until an owner-approved v1 release; all pre-v1 ships are MINOR/PATCH only (owner directive)
- ~~Single-user, local-first tool — no auth/multi-tenancy in MVP~~ **SUPERSEDED v0.3 (owner-directed 2026-09-29)**: multi-user hosted product — user/pass auth, admin-managed accounts (first registered user is admin), OAuth deferred to v0.4+; runs/library/settings partitioned by user
- Per-user model keys resolve per run: user's own key → server env-file key → keyless localhost fallback (owner directive)
- Not a general-purpose chatbot; not a model host; not a closed content index (v1)
- Paywalled academic access out of scope v1 — open APIs (ArXiv/PubMed) only
- Single-user, local-first tool — no auth/multi-tenancy in MVP
- Ollama/vLLM supported via OpenAI-compatible endpoints only (no vendor-specific adapters)
- Secrets never in app config or git; model capability honesty (warn when selected model is weak at structured outputs)
- No deploys without owner directive (D-019 carries); milestone merge to main halts at the owner approval gate (D-018 carries)
## Out of Scope
- Paywalled academic sources; model hosting; multi-tenant/auth; native Ollama API adapter; domain registration/rename decision
- Paywalled academic sources; model hosting; open self-serve registration / OAuth (admin-managed accounts only, OAuth v0.4+); native Ollama API adapter; domain registration/rename decision
## Context
Vision finalized and locked 2026-09-26: v1 methodology composition LOCKED (8 core), product named ResearchX, OpenBao retained as architectural secrets component, criticism matrix and evidence base [E1]–[E15] on file. Owner revisions 2026-09-27: (1) prototype-first — UI/UX prototype with mock data must be approved before MVP work begins; (2) version starts at v0.1, MAJOR remains 0 until MVP released; (3) the prototype is its own milestone. Milestone plan: v0.1 prototype (this milestone) → v0.2 MVP (gated).
@@ -56,8 +59,16 @@ Vision finalized and locked 2026-09-26: v1 methodology composition LOCKED (8 cor
## Key Decisions (v0.2 clarify — D-007..D-020, 2026-09-27)
- Backend layout: `backend/` repo-root package, src-layout `researchx` (config, methodologies, adapters, engine, evidence, trust, api per ARCHITECTURE §Components); uv + uv-managed Python 3.12 (D-007/D-008)
- Ports/binding: backend 127.0.0.1:8000, frontend :3000 via NEXT_PUBLIC_API_BASE_URL; CORS localhost:3000 (D-009); run IDs = 8-char slugs; storage = <dir>/runs/<id>/ (D-010)
- Ports/binding: backend 127.0.0.1:8000, frontend :3000 via NEXT_PUBLIC_API_BASE_URL; CORS localhost:3000 (D-009); run IDs = 8-char slugs; storage = <dir>/runs/<id>/ (D-010 — **superseded by D-106 for v0.3+: runs/<user_id>/<run_id>/**)
- Adapters: Ollama base URLs + key via env-file secrets scopes (OLLAMA_BASE_URL, OLLAMA_API_KEY); DDG keyless fallback = html.duckduckgo.com/html conservative scrape (D-011/D-012)
- Integration: openapi-typescript + openapi-fetch → frontend/src/lib/api/generated, bound behind ApiClient; RESEARCHX_API_MODE=mock|live, mock default (D-013/D-020)
- Transport/trust: sse-starlette SSE + pydantic typed event bus (D-014); difflib quote anchoring ≥ 0.85 (D-015); HTTP liveness live/stale/dead (D-016); JSON durable state, atomic rewrite per transition (D-017)
- Run termination: pipeline ships P1..P4 + P5 final patch, then HALTS at the owner approval gate before milestone → main (merge_to_main hook, v0.1 precedent); no deploys without owner directive (D-018/D-019)
- Run termination: pipeline ships P1..P4 + P5 final patch, then HALTS at the owner approval gate before milestone → main (merge_to_main hook, v0.1 precedent); no deploys without owner directive (D-018/D-019)
## Key Decisions (v0.3 clarify — D-101..D-115, 2026-09-29)
- Sessions: server-side store (in-process dict + durable sessions.json, D-017 idiom), httpOnly SameSite=Lax cookies, opaque tokens, 14-day sliding expiry, immediate revocation (D-101); argon2id via argon2-cffi, verified via asyncio.to_thread (D-102)
- Auth posture: 401 on all /api/* except login/register (register open only while user store empty — first user is admin, then admin-managed; D-103/D-104); CSRF = SameSite=Lax + custom header on non-GET (EventSource GET exempt); /healthz outside /api for proxy checks
- Identity/partitioning: user_id = 8-char slug, email login (D-105); storage runs/<user_id>/<run_id>/ (D-106 supersedes D-010); data root = RESEARCHX_DATA_DIR (backend/var/researchx dev, /var/lib/researchx container; D-107)
- Key store: AES-256-GCM with RESEARCHX_KEK from the env-file 'keys' scope, user_id as AAD; disabled honestly when no KEK (D-108); scope = model key only (D-109); model adapter constructed per run (fixes shared-adapter fallback bug; D-110)
- Settings: GET /api/config stays read-only adapter status (user-contextualized resolved tier); new GET/PUT /api/config/settings for per-user settings; storage path display-only hosted (D-111)
- Mock fate: code default stays mock (zero-key dev/CI); hosted image built with NEXT_PUBLIC_RESEARCHX_API_MODE=live; no live delegation to mock after P3 (D-112)
- REQ-82 is backend work: engine persists report.json + evidence.json at DONE; endpoints enriched (D-113); open questions = plan sub-questions answered-iff DONE (D-114); read-models read disk artifacts, not RunRegistry; in-flight runs marked cancelled on restart; rehydration out of v0.3 scope (D-115)
+32 -1
View File
@@ -80,6 +80,33 @@
- **D-013/D-020**: openapi-typescript + openapi-fetch generated client bound behind ApiClient; RESEARCHX_API_MODE=mock|live, mock default until P3
- **D-014..D-017**: sse-starlette SSE + pydantic event bus; difflib quote anchoring ≥ 0.85; HTTP liveness live/stale/dead; JSON durable state atomically rewritten per transition
## Hosted (Milestone v0.3 — owner-directed 2026-09-29)
### Auth & Sessions
- **REQ-69**: User/pass auth: user model with argon2id password hashing (argon2-cffi, verified off the event loop), register/login/logout/me routes, httpOnly cookie sessions (server-side session store: in-process dict + durable JSON under the data root, immediate revocation on logout/deactivation), CSRF-safe same-origin posture (SameSite=Lax cookie + custom-header check on mutating requests); OAuth explicitly deferred to v0.4+
- **REQ-70**: Admin-managed accounts: registration is open only while the user store is empty; the first registered user becomes admin; subsequently accounts are created by an admin only (admin-set initial password, no email infrastructure); admin can list/create/deactivate accounts (deactivation kills sessions; data retained)
- **REQ-71**: User scoping: every run, stored report, and setting is partitioned by owner — the engine, storage paths, and library read-model are user-scoped; storage layout becomes runs/<user_id>/<run_id>/ (supersedes D-010); owner_id is recorded in run state; no migration (pre-hosted artifacts disposable); no user can read or act on another user's data through the API
### Per-User Settings & Model Keys
- **REQ-72**: Per-user settings persistence (PUT/GET /api/config/settings): methodology defaults, resolved dials, appearance slot, masked key status; storage directory is server-managed per-user — display-only in hosted mode (the free-path picker applies to the local single-user profile only)
- **REQ-73**: Per-user model API keys: masked entry in Settings (never returned in full — masked status only), encrypted at rest (per-user key store; KEK = RESEARCHX_KEK from the env-file secrets 'keys' scope; cipher AES-256-GCM with user_id as AAD; without a configured KEK the key store is disabled with honest status — chain falls to server/keyless); scope = model key (OLLAMA_API_KEY) only in v0.3
- **REQ-74**: Per-run model credential resolution chain: user's own key → server env-file key → keyless localhost:11434 fallback (existing fallback semantics preserved); the model adapter is constructed per run (fixes the shared-adapter fallback bug); the capability probe runs against the resolved tier; config status surfaces which tier resolved (user|server|keyless) for the current user
- **REQ-75**: Real inference as the product default: the code default of RESEARCHX_API_MODE stays mock (zero-key dev/CI per REQ-31); the hosted frontend is built with NEXT_PUBLIC_RESEARCHX_API_MODE=live (build-time inlining); after v0.3 P3 no live-mode surface delegates to the mock; mock demoted to a dev/test fixture
### Live Surfaces (G-002 boundary lifted, user-scoped)
- **REQ-76**: Library backend: GET /api/library — folder-structure listing over the user's runs (auto-categorization by topic + methodology, recency/title sorting); report cards resolve to real runs
- **REQ-77**: Cross-report connections + project graph: GET /api/library/connections and GET /api/graph — shared-source and topical edges computed from the user's ledgers and run metadata
- **REQ-78**: Task inbox: GET /api/tasks — open questions from the user's runs as checkable follow-ups; open questions = plan sub-questions of the user's runs, answered-iff the run reached DONE
- **REQ-79**: Suggestions: GET /api/suggestions — follow-up query suggestions derived from the user's unanswered questions (open inbox items joined with source runs); click-fills-the-box semantics preserved; empty state for users with no runs (no synthetic suggestions)
- **REQ-80**: Refine: POST /api/refine/questions + /api/refine/compose — planner-model-driven clarifying exchange composing a refined question + suggested context (scripted prototype dialogue replaced by real model calls)
- **REQ-81**: User profile: GET/PUT /api/profile — local profile (display name, email) surfaced in the avatar user menu; no account deletion in v0.3
- **REQ-82**: Live Reading Room fidelity: the live report renders structured sections with page-anchored citations (hover shows the stored quoted passage) and the ledger panel from the citation ledger — parity with the mock Reading Room v0; the engine persists report.json (structured sections + anchored citations with quotes) and evidence.json (passage texts) at DONE; report/ledger endpoints expose them; live exports use real entry data (no fabricated fields)
### Hosting
- **REQ-83**: Backend container: Dockerfile for the FastAPI backend (single uvicorn worker, non-root), two-service deployment (frontend + backend) via deploy.sh extension; RESEARCHX_DATA_DIR volume (default /var/lib/researchx); unauthenticated /healthz outside /api for proxy health checks
- **REQ-84**: SSE-safe reverse proxy: HAProxy path routing /api → backend with streaming timeouts that do not buffer or cut long-lived SSE connections; frontend served with NEXT_PUBLIC_API_BASE_URL pointing at the public origin
- **REQ-85**: Hosted live mode: the deployed instance runs real inference in live mode (per REQ-75), per-user keys honored; auth guard covers the backend surface (no unauthenticated API access through the public URL)
## V2 (Post-MVP backlog)
### Methodology Catalog Extension
@@ -112,7 +139,7 @@
|---|---|
| Paywalled academic access | Unsolved in v1 — routed around via open APIs (vision §8) |
| General-purpose chatbot / model host / closed content index | Explicit non-goals (vision §8) |
| Auth / multi-tenancy | Single-user local-first MVP |
| Auth / open self-serve registration / OAuth | v0.3 ships user/pass auth with admin-managed accounts (owner-directed 2026-09-29); open registration and OAuth stay out (OAuth v0.4+) |
| Native Ollama API adapter | Ollama supported via OpenAI-compatible endpoint only |
| Domain registration / product rename | Deferred by owner (vision §11) |
| v1.0.0 release | Owner-approved release only; not created autonomously by the pipeline |
@@ -134,4 +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 | complete (v0.3.1; security review P0 register race + P1 corrupt-store fail-closed fixed in-phase; verify PASS 4/4) |
| REQ-72..74 (settings, per-user keys, resolution chain) | v0.3 | P2 | pending |
| REQ-76..82 (live surfaces, user-scoped) | v0.3 | P3 | pending |
| REQ-75 (delegation-end half) + REQ-83..85 (hosting; live default hosted) | v0.3 | P3/P4 (REQ-75 split per G-103: delegation-end P3, hosted-default P4) | pending |
| REQ-35..51 | v2 backlog (post-MVP milestones) | — | pending |
+555
View File
@@ -0,0 +1,555 @@
# Research — v0.3 Hosted (Phase 0)
## Run: 2026-09-29 (stage: research)
Phase-0 research findings for milestone v0.3 (Hosted): auth + server-side
sessions, per-user encrypted model keys, user-scoped storage, live surfaces
backed by disk artifacts, and two-service hosting behind an SSE-safe proxy.
Each topic records concrete code/config shapes and pitfalls that PLAN
consumes. All library claims were verified against the pinned versions in
the repo (starlette 0.46.2, fastapi 0.115.14, sse-starlette, eventsource
polyfill 5.1.2).
## 1. FastAPI auth/session layer
**Recommendation:**
- New package `backend/src/researchx/auth/`: `users.py` (user model +
argon2id), `sessions.py` (server-side session store), `guard.py`
(session + CSRF dependency), `routes.py` (register/login/logout/me).
- **Route dependencies, not middleware, as the enforcement point.** Group
all protected routes under `APIRouter(dependencies=[Depends(require_user)])`
inside `create_app`. The dependency resolves the session cookie, loads
the user, runs the CSRF header check on non-GET, and returns the `User`:
```python
async def require_user(request: Request) -> User:
token = request.cookies.get(SESSION_COOKIE) # "researchx_session"
session = sessions.get(token) # None if absent/expired/revoked
if session is None:
raise HTTPException(status_code=401, detail="authentication required")
if request.method != "GET" and "x-researchx-request" not in request.headers:
raise HTTPException(status_code=403, detail="missing request header")
user = 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 user
```
Rationale vs global middleware: the dependency appears in OpenAPI (401
documented), is overridable per-test via `app.dependency_overrides`
(critical for the zero-key harness, topic 9), and avoids the pure-ASGI
middleware rewrite needed to keep SSE streaming safe. A public router
(`/api/auth/*`, `/healthz`) + protected router (everything else)
enforces "all /api/* except login/register" by construction.
- **Session store (D-101):** opaque token = `secrets.token_urlsafe(32)`;
in-process `dict[token, Session]` + durable `sessions.json` at the data
root, written with the atomic tmp+`os.replace` idiom (D-017).
`Session = {token_hash, user_id, created_at, last_seen, expires_at}`.
14-day sliding expiry via `touch()`; persist only when >60s since last
persist. Immediate revocation = delete token + persist. Logout deletes
the presented token; deactivation deletes all of the user's tokens.
Rotate the token at login (kills session fixation by construction).
- **Cookie:** `researchx_session=<token>`; `httponly=True, samesite="lax",
path="/"`, `secure=True` only in hosted/TLS deploys (config flag), no
`Domain` attribute (host-only).
- **argon2-cffi (D-102):** `PasswordHasher()` defaults are argon2id with
the RFC 9106 low-memory profile — keep the defaults. All calls off the
event loop via `asyncio.to_thread`; `VerifyMismatchError` → uniform 401
"invalid email or password"; `check_needs_rehash` → rehash on login.
- **CORS ordering (verified):** the LAST-added middleware is OUTERMOST
(starlette 0.46.2 `build_middleware_stack`). Add `CORSMiddleware` AFTER
any auth middleware so preflight OPTIONS is answered without a 401.
Cookie auth requires `allow_credentials=True` + explicit localhost
origins (browsers reject wildcard origins with credentials).
- **First-user-is-admin (D-104):** `POST /api/auth/register` checks
`users.empty()` then inserts `role="admin"`; no `await` between check
and insert except the argon2 hash — the single uvicorn worker makes
this race-free by construction. After the first user, register 403s;
`POST /api/admin/users` (admin-guarded) creates accounts.
- **User store:** `users.json` at the data root:
`{"users": [{"user_id", "email", "password_hash", "display_name",
"role": "admin"|"user", "active": true, "created_at"}]}`; `user_id` =
8-char slug from the run-ID alphabet + validation regex `^[a-z0-9]{8}$`
(D-105 — filesystem-safe partition key).
**Pitfalls:**
- Do NOT use `BaseHTTPMiddleware` for the guard — it wraps response bodies
and has a history of SSE/streaming buffering bugs; router dependencies
sidestep the class.
- SameSite site-computation is scheme+registrable domain, NOT port —
`localhost:3000 → localhost:8000` is same-site (Lax cookie flows with
`credentials:"include"`), but `localhost:3000 → 127.0.0.1:8000` is
CROSS-site (Lax cookie silently dropped). Dev docs must pin both sides
to `localhost` (`NEXT_PUBLIC_API_BASE_URL=http://localhost:8000`).
- argon2 verify at default params takes ~50–100ms CPU — `to_thread`
mandatory for hash and verify.
- `EnvFileSecretsProvider` loads at construction; user/session/key stores
are mutable and must reload-or-rewrite atomically instead.
## 2. CSRF posture (D-103)
**Recommendation:**
- Combined defense, no token round-trips: (1) `SameSite=Lax` session
cookie blocks cross-site cookie attachment on background POSTs; (2)
every non-GET `/api` request must carry `X-ResearchX-Request: 1`
(checked in `require_user`). A cross-site attacker can neither attach
the Lax cookie to a background POST nor set custom headers without
CORS preflight approval.
- Frontend: the live client sends the header on every mutating call —
a small `#req()` wrapper or openapi-fetch middleware in
`GeneratedApiClient`: `headers: { "X-ResearchX-Request": "1" }`.
- **EventSource is exempt by design:** GET-only, cannot set custom
headers — the guard skips GETs, so `/api/runs/{id}/events`
authenticates by cookie alone. The eventsource polyfill (5.1.2) used
in E2E supports `withCredentials` and a fetch override.
- Login/register POSTs also require the header (uniform rule).
- Dev split-origin: `fetch(..., {credentials: "include"})` everywhere +
CORS `allow_credentials=True` + same-hostname origins (§1 pitfall).
**Pitfalls:**
- If dev ever needs cross-SITE cookies (localhost vs 127.0.0.1), Lax
fails silently — the fix is same-hostname origins, not `SameSite=None`.
- `Secure` must NOT be set in plain-HTTP dev (browsers drop Secure
cookies on http); MUST be set on the TLS-fronted host.
## 3. Per-user encrypted key store (D-108, D-109)
**Recommendation:**
- New `backend/src/researchx/auth/key_store.py` using
`cryptography.hazmat.primitives.ciphers.aead.AESGCM`
(`AESGCM(key).encrypt(nonce, data, aad)` → ciphertext||16-byte tag;
`decrypt` raises `InvalidTag` on mismatch; nonce = 12 bytes; NEVER
reuse a nonce under the same KEK).
- **KEK (D-108):** `RESEARCHX_KEK` from the env-file **'keys' scope**
(`config.json secrets.scopes` gains `"keys": ["RESEARCHX_KEK"]`),
accepted as urlsafe-base64 or hex of exactly 32 bytes; decode errors
raise `AdapterError("RESEARCHX_KEK is not valid b64/hex")`; wrong
length raises (`must decode to 32 bytes (AES-256)`).
- **Envelope:** AAD = `user_id.encode()` (a copied file under another
user's directory fails `InvalidTag`); fresh `os.urandom(12)` nonce per
write; scope = `OLLAMA_API_KEY` only (D-109).
- **File format** `keys/<user_id>.json` under the data root, atomic
rewrite, `0600`:
```json
{
"v": 1,
"alg": "AES-256-GCM",
"updated_at": "2026-09-29T12:00:00Z",
"entries": {
"OLLAMA_API_KEY": {"nonce": "<b64url(12)>", "ct": "<b64url(ct+tag)>", "updated_at": "..."}
}
}
```
- **API never returns key material** (REQ-73): PUT accepts a raw key, GET
returns `{"configured": true, "masked": "…3f2a"}` (last 4 chars).
Crypto in `asyncio.to_thread` for discipline.
- Honest degradation: no KEK → PUT returns `{"key_store": "disabled"}`;
resolution chain proceeds server/keyless; corrupt entries (InvalidTag)
surface as error status, not silent tier drop.
**Pitfalls:**
- GCM nonce reuse under a fixed KEK is catastrophic — always `os.urandom`.
- KEK rotation is out of v0.3 scope; `v` + `entries` leave room later.
- A changed KEK requires process restart (EnvFileSecretsProvider loads
once) — document it.
## 4. Per-run model adapter factory (D-110)
**Recommendation:**
- Verified shared-adapter bug (`ollama.py:73-79`): a cloud failure
permanently flips `self._active_base` on the SHARED instance — every
subsequent run by every user silently uses localhost. Fix = per-run
adapter instances.
- New `backend/src/researchx/adapters/model_factory.py`:
`ModelAdapterFactory(secrets, key_store, defaults)` with
`async resolve(user, settings) -> (OllamaModelAdapter, tier)` resolving
the chain user → server → keyless (REQ-74) and constructing one
adapter per run.
- `OllamaModelAdapter` constructor gains optional `api_key: str | None`
taking precedence over `secrets.resolve(...)`; the secrets path stays
for back-compat. Fallback mutation stays — now per-instance.
- **Threading through:** `create_app` constructs the factory once;
`create_run` does `model, tier = await factory.resolve(user, settings)`
and passes `model` into `RunActor` — **engine contracts unchanged**;
only the instance origin changes. `RunRegistry` keys become
`(owner_id, run_id)`. `state.json` records `owner_id` and
`credential_tier` for auditability.
- **Probe-per-run:** the existing `supports_probe` + `probe_model_adapter`
+ `actor.attach_probe(...)` flow operates on the adapter passed to
`create_run` — per-tier automatically. `GET /api/config` resolves the
factory for the current user WITHOUT creating a run; cache in-process
keyed by `(user_id, tier, key_fingerprint)` with short TTL.
**Pitfalls:**
- `config_status` reads `runs._actors` (private) to steal the last run's
probe (`app.py:174-177`) — breaks with zero runs and leaks another
user's probe; replace with the factory-resolved probe.
- Don't cache adapters across runs — per-run lifetime keeps
`_last_fallback_reason` honest.
## 5. Storage partitioning (D-106, D-107) — exact touchpoints
**Recommendation:**
- **Owner-scoped adapter instances, not owner-in-signatures.**
`LocalStorageAdapter(base_dir, owner_id)` binds the owner at
construction; `run_dir(run_id) -> base_dir/"runs"/owner_id/run_id`.
The `StorageAdapter` protocol stays byte-for-byte unchanged; only
constructors gain `owner_id` (validated `^[a-z0-9]{8}$`, D-105).
- App wiring: routes construct the owner-scoped adapter from the auth
dependency per request. `RunState` gains `owner_id: str | None = None`
(serialization-compatible; no migration per D-106).
`make_run_config(...)` gains `owner_id` and stamps it on state.
- `RunRegistry` keys `_actors[(owner_id, run_id)]`; cross-owner access
is a 404.
- **D-107 data root:** new `researchx.config.paths.data_root()`:
`os.environ["RESEARCHX_DATA_DIR"]` else `backend/var/researchx` (dev);
container `/var/lib/researchx`. Replaces the cwd-dependent
`Path.cwd()/".researchx"` (`app.py:150`). Layout:
```
$RESEARCHX_DATA_DIR/
users.json sessions.json
runs/<user_id>/<run_id>/{state.json, trace.jsonl, report.md,
report.json, evidence.json, ledger.json, checklist.md}
keys/<user_id>.json
settings/<user_id>.json
```
- **Exact touchpoint list (verified in-tree):**
1. `adapters/local_storage.py` — `__init__(owner_id)`, `run_dir`, +
`list_run_ids() -> list[str]` (read-models scan).
2. `adapters/storage.py` — protocol unchanged; add `append_run_artifact`
(actor uses it, protocol lacks it).
3. `engine/run_state.py` — `owner_id` field.
4. `engine/actor.py` — owner-scoped storage; `make_run_config(owner_id)`;
artifacts at DONE (topic 6).
5. `api/app.py` — per-request storage, registry keys, config display =
data root, `create_app(..., auth_enabled: bool = True)`.
6. `api/dev_bridge.py` — owner + auth off.
7. `engine/demo.py` — storage constructor gains owner.
8. Tests constructing storage/actor: `test_local_storage.py`
(layout assertion + traversal), `test_engine.py`,
`test_probe_wiring.py`, `test_adversarial.py`, `test_resume.py`,
`test_integration_p1.py`, `test_p0_regressions.py`, `test_api.py` —
mechanical `owner_id="u1"`-style constructor args.
9. Frontend untouched by partitioning (ids stay run-scoped).
**Pitfalls:**
- `owner_id` must be REQUIRED in `LocalStorageAdapter` — an optional
default invites silent unpartitioned use.
- `read_run_artifact`'s error message embeds `runs/{run_id}/{name}` —
update to owner path; tests may pin it.
- Consolidate the duplicate run-id generators (`local_storage` vs
`run_config.new_run_id` — different alphabets) when touching both.
## 6. New persisted artifacts: report.json + evidence.json (D-113)
**Recommendation:**
- Persist both at DONE inside `_verify_phase` (actor.py:315-332), right
after `report.md`/`ledger.json` — deterministic, no model calls.
- **Marker normalization (frontend-compat crux):** the writer emits
`[p-N]` (trust loop regex `\[p-(\d+)\]`); the Reading Room parses
`[^pN]` with `[A-Za-z0-9]+` only (`report-body.ts:18` — `[^p-1]` does
NOT match). Normalize at persistence: in `report.json` section bodies
`[p-3]` → `[^p3]`, ids `p-3` → `p3`. `report.md` keeps the raw form.
- **report.json**: `{v, run_id, owner_id, title, question, methodology_slug,
generated_at, sections: [{heading, body}], citations: [{passage_id, url,
title, quote, health, authority_tier}]}`. Sections split from
report.md headings; title = first H1 else `"Deep Report — {question}"`.
- **evidence.json**: `{v, run_id, passages: [{passage_id, url, title,
quote, text}]}` — serialized from `EvidenceStore` (ids renumbered `pN`).
- **Endpoint enrichment:** `GET /api/runs/{id}/report` returns
`{run_id, markdown, report: {...}, evidence: {...}}` (markdown retained,
back-compat); ledger unchanged. Live `exportReport` switches to real
fields — kills the fabricated `claims_supported: 1` and empty quotes
(`live-client.ts:329-384`).
- Live-client `getReport` maps report.json → frontend `Report`
(citations → `Citation` with real quotes; tier map official→1,
reference→2, else 3).
**Pitfalls:**
- Do NOT change `report.md`'s marker syntax — `_claims_per_passage`,
`extract_report_claims`, `_cited_urls` regex on `\[p-(\d+)\]`.
- Old DONE runs lack report.json — the endpoint must fall back to
markdown-only gracefully (pre-hosted artifacts disposable per D-106).
- `EvidenceStore` is per-actor and GC'd after the run — evidence.json
MUST be written inside `_verify_phase` (D-115 disk-reads later).
## 7. Live read-models on disk artifacts (D-114, D-115)
**Recommendation:**
- Read-models read disk, never RunRegistry (D-115): a small
`api/read_models.py` scans `runs/<owner_id>/*/` loading `state.json`
(+ report.json/ledger.json where needed).
- **Restart sweep:** on `create_app` startup, rewrite every non-terminal
`state.json` as CANCELLED (legal from every non-terminal state,
verified `states.py`); in-flight actors die with the process;
rehydration out of scope (D-115).
- **Registry pruning:** `_actors`/`_buses` are never pruned today (slow
multi-user leak); drop terminal entries once no live SSE subscriber
(refcount in the events route); events 404 after prune/restart is
documented, trace persists on disk.
- **Open-question semantics (D-114):** open questions = plan
sub-questions of the user's runs, answered-iff `state == DONE`; no
synthetic suggestions; empty states for new users.
- Route shapes (owner-scoped via the auth dependency):
- `GET /api/library?sort=recent|title` → folders (auto-categorized by
methodology + deterministic topic tokens from plan sub-questions) +
`ReportSummary[]`.
- `GET /api/library/connections` → shared-source edges from ledger URL
overlap (≥1 shared URL); related-topic edges on methodology +
category overlap.
- `GET /api/graph` → same edges in the frontend `ProjectGraph`
node/edge shape.
- `GET /api/tasks` → `InboxItem[] {id: "<run_id>#<idx>", question,
sourceRunId, status}` (done-iff-run-DONE else open).
- `GET /api/suggestions` → open items joined with run titles:
`SuggestedQuery[] {id, question, sourceRunId, sourceReportTitle}`.
- `POST /api/refine/questions {draft}` → planner call (2-3 clarifying
questions); `POST /api/refine/compose {draft, answers}` → planner
composes refined question + context. Non-GET ⇒ CSRF header applies.
- `GET/PUT /api/profile` → display_name/email.
- `GET/PUT /api/config/settings` (D-111): per-user settings
(`settings/<user_id>.json`): model names, methodology defaults,
masked key status, storage path display-only.
- `GET /api/config` stays read-only, user-contextualized:
`resolved_tier: user|server|keyless`, `key_store: enabled|disabled`,
per-user cost counters (owner-keyed), probe (§4).
- Directory scans skip dotfiles/tmp leftovers defensively.
- Refine endpoints spend planner tokens — same credential chain; a
keyless-only user with no local Ollama gets an honest 503, not a hang.
## 8. Hosting: backend Dockerfile + two-service deploy (REQ-83)
**Recommendation:**
- `backend/Dockerfile` (uv idiom, non-root uid 10001, single worker):
`python:3.12-slim` + uv from ghcr image; `uv sync --frozen --no-dev`;
`useradd --system --uid 10001 researchx`; `mkdir -p /var/lib/researchx`
+ chown; `USER researchx`;
`CMD ["uvicorn", "researchx.api.main:app", "--host", "0.0.0.0",
"--port", "8000", "--workers", "1", "--proxy-headers"]`.
`--workers 1` is a hard requirement (in-memory registry + sessions).
- **New production entrypoint `researchx.api.main`:** today `app.py`'s
module-level `app = create_app()` serves **FakeModelAdapter +
cwd-based storage** — hosting that would silently ship fakes.
`main.py` wires `create_app(data_root=RESEARCHX_DATA_DIR, factory=
<live factory from env-file secrets>, auth_enabled=True)`. `app.py`
stays the test factory.
- Secrets at runtime only: `RESEARCHX_KEK`, `OLLAMA_API_KEY`,
`OLLAMA_BASE_URL`, `TAVILY_API_KEY` via `--env-file` — never baked
into the image, never in git.
- **Plain docker-run pairs, not compose** — matches the existing
deploy.sh idiom and avoids a compose dependency on the LXC host:
`researchx-net` bridge network; `researchx-api` backend (env-file,
data volume, `-p 127.0.0.1:8000:8000` if HAProxy is host-side);
`researchx` frontend (`-p 3000:3000`, live build args). Keep
`researchx-api-prev` + `researchx-prev` for rollback.
- **deploy.sh extension:** rsync now includes `backend/`; build both
images on the host; health check BOTH `/healthz` (backend) and `/ask`
(frontend); update the HAProxy backend section idempotently.
- Data volume: pre-create + `chown 10001` host-side (belt-and-braces).
- New pyproject deps: `argon2-cffi>=23.1,<26`, `cryptography>=43`; no
`python-multipart` (JSON login bodies only).
**Pitfalls:**
- The volume must be writable by uid 10001 — a root-owned bind mount
makes every artifact write EACCES.
- uvicorn graceful shutdown default lets in-flight runs exercise
`_persist_cancelled` on redeploy; `docker rm -f` SIGKILLs — the
startup sweep (§7) is the real safety net.
## 9. HAProxy SSE configuration (REQ-84)
**Recommendation:**
- The host ALREADY runs HAProxy 2.8 with TLS for research.coreci.dev —
this is a front-config extension, not a new component:
```
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
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 frontend_next
timeout server 300s
server next1 127.0.0.1:3000 check
```
- HAProxy 2.x does not buffer response bodies by default in HTTP mode —
SSE streams natively. The three buffering hazards: NO
`option http-buffer-response`, NO compression on the API backend
(gzip buffers text/event-stream), keep default http-keep-alive.
- Verified: `sse-starlette` sends `Cache-Control: no-store` +
`X-Accel-Buffering: no` and emits `: ping` every **15s**
(`DEFAULT_PING_INTERVAL = 15`) — defeats idle timeouts.
- `timeout client` in global/defaults must exceed the 15s ping interval.
- Health check on `/healthz` (unauthenticated, outside /api per D-103):
`GET /healthz → 200 {"status":"ok"}`.
**Pitfalls:**
- Enabling global compression "to save bandwidth" is the classic way SSE
silently degrades — scope it off for the API backend.
- Don't disable the sse-starlette ping.
## 10. Next.js build-time env + EventSource credentials (D-112)
**Recommendation:**
- `NEXT_PUBLIC_*` vars are inlined at BUILD time — two build profiles:
- **dev/test (default):** no env; mode resolves mock; zero keys
(REQ-31 preserved, D-112).
- **hosted:** `pnpm build` with
`NEXT_PUBLIC_RESEARCHX_API_MODE=live NEXT_PUBLIC_API_BASE_URL=""` —
empty base = same-origin through HAProxy's `/api` path route.
`baseUrl()` treats `""` as same-origin (openapi-fetch accepts a
relative base; EventSource accepts relative URLs). Pin dev live-mode
docs to `http://localhost:8000` (§1 hostname rule).
- `deploy.sh` frontend build gains the build args; the Dockerfile build
stage passes them (`ARG`/`ENV` before `pnpm build`).
- EventSource: `new EventSource(url, { withCredentials: true })` — the
polyfill supports the same init dict. Same-origin hosted mode doesn't
strictly need it; set it anyway.
- **Delegation ends (D-112):** `GeneratedApiClient extends MockApiClient`
today; in P3 every method is overridden against real endpoints and the
inheritance is dropped (a standalone class implementing `ApiClient`);
mock remains standalone. OpenAPI snapshot regen (G-005): the new
auth/settings/library/refine/admin routes change `openapi.json` —
commit the snapshot, regen the generated schema, add the ApiClient
interface methods (`login/logout/getSession`, admin hooks) as PLANNED
extensions (the v0.2 G-002 lesson: document every addition up front).
**Pitfalls:**
- Forgetting the build args yields a hosted instance silently running
mock mode — add a deploy.sh smoke assertion: hosted `/api/config`
returns 401 + a live-mode marker.
- A non-empty `NEXT_PUBLIC_API_BASE_URL` in the hosted build breaks
same-origin cookies — assert `""` at build.
## 11. Dev/E2E harness under auth
**Recommendation:**
- `create_app(..., auth_enabled: bool = True)`. When False, the guard
resolves a synthetic dev user (`user_id="dev00000"`, role="admin") —
every route behaves exactly as authenticated, owner-scoped, zero keys.
- Existing suites keep running unchanged: backend API tests
(`test_api.py`, `test_adversarial.py`) and the real-stack E2E
(`dev_bridge.py`) construct with `auth_enabled=False`. Engine/trust
suites only need the `owner_id` constructor args (topic 5).
- **New auth suite (`test_auth.py`) with `auth_enabled=True`:**
first-user-admin (+403 after), wrong password 401, logout revocation,
401 on protected routes, CSRF header rejection, cross-user 404,
admin create/deactivate (kills sessions), expiry, key-store
round-trip + AAD tamper rejection + masked-only responses.
**Pitfalls:**
- Don't gate `auth_enabled` on an env var readable in production images
(a baked `RESEARCHX_AUTH=off` violates REQ-85); parameter-only, and
`main.py` never passes it.
- The E2E custom fetchImpl forwards headers verbatim — once the live
client adds the CSRF header, E2E keeps working via GETs + dev-disabled
guard; note for the plan, don't build now.
## 12. Pitfall scan — current code that breaks under multi-user
1. **`config_status` reads `runs._actors`** (`app.py:174-181`) —
multi-user probe leak + zero-run breakage. → factory probe (§4). [P1]
2. **Shared `model` instance across runs** (`app.py:152`) — the verified
`_active_base` fallback mutation (`ollama.py:73-79`) flips every user
to localhost after one cloud 401. → per-run factory (§4). [P1]
3. **`app = create_app()` module global** (`app.py:359`) — serves fakes
+ cwd storage in production. → `researchx.api.main` (§8). [P2/P4]
4. **CORS lacks `allow_credentials`** (`app.py:143-148`) — cookie auth
cannot work cross-origin in dev. → §1. [P1]
5. **`dev_bridge.py` no auth** — correct for E2E; must stay explicitly
`auth_enabled=False`, never the production entry. [verify P1]
6. **`RunRegistry` unbounded growth** — prune terminal runs. → §7. [P1]
7. **`RunState` no `owner_id`** — cross-user authorization impossible
today. → topic 5. [P1]
8. **Read endpoints require the registry** (`runs.has(run_id)` guards)
— every read dies on restart; a hosted redeploy 404s the whole
library. → disk read-models (D-115). [P1/P3]
9. **`demo.py`** constructs storage directly — mechanical owner fix. [P1]
10. **`test_local_storage.test_run_dir_layout`** asserts unpartitioned
path — changes with D-106 (intentional). [P1]
11. **Dead code** duplicate `return EventSourceResponse` (app.py:331) —
remove while touching the file. [P1]
12. **`exportReport` fabricates data** (`live-client.ts:350-389`) —
fixed by report.json/evidence.json. [P3]
13. **Global `cost_counters`** (`app.py:110`) — per-user (owner-keyed). [P1/P2]
14. **Duplicate run-id generators** (different alphabets) — consolidate. [P1]
## Research Conclusions
- **C3-01** Auth enforcement = router-level `Depends(require_user)` in
`create_app` (public router `/api/auth/*` + `/healthz`; protected the
rest), NOT middleware: OpenAPI-visible, dependency_overrides testable,
no BaseHTTPMiddleware/SSE interaction.
- **C3-02** Sessions: opaque `token_urlsafe(32)` cookies
(`researchx_session`, httpOnly, SameSite=Lax, Secure hosted-only),
server-side dict + atomic sessions.json, 14-day sliding expiry with
>60s persist throttle, token rotation at login, immediate revocation.
- **C3-03** argon2id via argon2-cffi `PasswordHasher()` defaults; hash,
verify, rehash-on-login all in `asyncio.to_thread`; uniform 401.
- **C3-04** CORS: `allow_credentials=True` with explicit localhost
origins, added so it wraps auth (last-added = outermost in starlette
0.46.2); dev origins must share the hostname `localhost` (port is not
part of same-site computation).
- **C3-05** CSRF = SameSite=Lax + `X-ResearchX-Request` header on all
non-GET /api (checked in the auth dependency); EventSource GET exempt;
fetch `credentials:"include"` throughout.
- **C3-06** Key store: AES-256-GCM via cryptography AESGCM, KEK =
32-byte RESEARCHX_KEK (b64/hex) from the env-file 'keys' scope, AAD =
user_id, `os.urandom(12)` nonce per write, `keys/<user_id>.json`
(v1/entries format, atomic, 0600); masked-only API; honest disabled
status; InvalidTag surfaced.
- **C3-07** Per-run model factory: `resolve(user) → (adapter, tier)`,
chain user→server→keyless; OllamaModelAdapter gains optional `api_key`
constructor param; engine contracts unchanged; probe-per-tier falls
out of the existing flow; GET /api/config = current user's tier +
fingerprint-cached probe.
- **C3-08** Partitioning = owner-scoped `LocalStorageAdapter(base_dir,
owner_id)` (protocol unchanged), `runs/<owner_id>/<run_id>/`, owner_id
in RunState, registry keyed (owner_id, run_id), per-request adapters;
~10 enumerated touchpoints incl. 8 test files + demo.py + dev_bridge.
- **C3-09** Data root = RESEARCHX_DATA_DIR (dev backend/var/researchx,
container /var/lib/researchx) via a new `paths.data_root()`; the
module-level create_app() cwd default + fake adapters are replaced by
a production `researchx.api.main` entry.
- **C3-10** report.json + evidence.json persisted at DONE in
_verify_phase; markers normalized `[p-N]`→`[^pN]`, ids `p-N`→`pN` at
the persistence boundary so the Reading Room parses with zero
frontend changes; report.md keeps `[p-N]` for the trust loop.
- **C3-11** Read-models read disk artifacts only; open questions = plan
sub-questions answered-iff DONE; startup sweep marks non-terminal
runs CANCELLED; rehydration out of scope.
- **C3-12** Registry pruning: drop terminal entries once no SSE
subscriber; events 404 after prune/restart documented; trace persists.
- **C3-13** Backend container: uv-based non-root Dockerfile (uid 10001,
chowned volume), `uvicorn --workers 1` mandatory, runtime-only secrets
via --env-file, plain docker-run pairs (no compose dependency),
researchx-api-prev rollback, health on both /healthz and /ask.
- **C3-14** HAProxy: path route /api + /healthz → backend (2.8 on the
LXC host, already TLS-fronting); timeout server/tunnel 3600s, NO
http-buffer-response, NO compression on the API backend; sse-starlette
15s pings + X-Accel-Buffering keep streams alive; httpchk GET /healthz.
- **C3-15** Two build profiles: code default mock; hosted image built
with NEXT_PUBLIC_RESEARCHX_API_MODE=live +
NEXT_PUBLIC_API_BASE_URL="" (same-origin); deploy smoke asserts live
mode; EventSource withCredentials; delegation to MockApiClient ends in
P3 (standalone GeneratedApiClient).
- **C3-16** Auth harness: create_app(auth_enabled=True) default with a
parameter-only dev/test override resolving a synthetic dev user;
existing zero-key suites run with it off; dedicated auth suite on.
- **C3-17** New pyproject deps: argon2-cffi>=23.1,<26, cryptography>=43;
no python-multipart; config.json secrets scopes gain
"keys": ["RESEARCHX_KEK"].
- **C3-18** ApiClient interface additions are PLANNED (G-002 lesson):
login/logout/getSession + admin hooks documented in PLAN before P1;
openapi.json snapshot regen is a P1 gate (G-005 discipline carries).
- **C3-19** Pitfalls P-1..P-14 (§12) each closed by the phase touching
its file: P-1..P-11, P-13, P-14 in P1; P-3 in P2/P4; P-12 in P3;
P-5 verified by the auth suite.
- **C3-20** All decisions D-101..D-115 confirmed workable against the
actual code; refinements folded into PLAN as constraints.
+46
View File
@@ -129,6 +129,52 @@ library/settings/suggestions/refine/graph stay mock-backed until
v0.3+. Milestone branch merged to main and deleted — tags
v0.2.0–v0.2.5 preserve the full history. Next: v0.3 planning.
## Milestone v0.3 — Hosted (feature) — owner-directed 2026-09-29
Scope (owner session 2026-09-29): one milestone combining the G-009-ratified live surfaces + hosting with three owner additions — auth/sessions (user/pass, admin-managed, OAuth deferred), per-user model API keys, and real inference as the product default. Tags run on the v0.3.x line; MAJOR stays 0; feature milestone — each phase ships a patch, the final phase's patch IS the milestone release.
### Phase 0 — Pre-Execution (v0.3)
- **Description**: SPECIFY (v0.3 requirement set REQ-69..85; single-user constraint superseded), CLARIFY (auth/session mechanics, key-encryption, partitioning, mock's fate, Reading-Room fidelity), RESEARCH (FastAPI session/auth patterns, encrypted key stores, SSE-proxying, storage read-model), PLAN (vertical slices), GRILL, MVP/UX check.
- **Requirements covered**: none (planning artifacts)
- **Success criteria**: .ciagent/ files refreshed for v0.3; PLAN.md carries User-Facing Surface + Happy Path + UX Acceptance Criteria; PERSONAS.md v0.3 roster active; grill verdict recorded with binding decisions
- **Depends on**: v0.2 approval (recorded)
- **Status**: complete (PR #16 squash-merged to milestone/v0.3-hosted 1adfa87, tag v0.3.0, release created; D-101..D-115 clarified; RESEARCH-v03 with C3-01..C3-20; PLAN P1..P4 vertical slices; grill Proceed 0.68 with G-101..G-110 bound, G-111 escalated; MVP/UX PASS)
### Phase 1 — Auth + Sessions + User Scoping
- **Description**: User model (argon2id), register/login/logout/me, httpOnly cookie sessions (server-side store), first-user-is-admin + admin-managed accounts (REQ-70), auth guard on every API endpoint, runs/settings partitioned by owner (engine + storage paths). Frontend: login screen, real profile in user menu, logout.
- **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**: 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
### 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
### 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.
- **Requirements covered**: REQ-75, REQ-83, REQ-84, REQ-85
- **Success criteria**: one-command deploy brings up both services; SSE streams unbuffered through the proxy; unauthenticated curl to the public /api returns 401; live-mode default proven on the staged instance
- **Depends on**: 3
- **Status**: pending
### Phase 5 — Final Review + Ship (FINAL)
- **Description**: Multi-persona review of the full milestone diff, audit, tag v0.3.5 = milestone release; pipeline HALTS at the merge_to_main owner approval gate (D-018 carries); deployment to research.coreci.dev only on owner directive (D-019 carries).
- **Requirements covered**: milestone gate (REQ-69..85)
- **Success criteria**: review PASS (P0s fixed in-phase); audit PASS; v0.3.5 tagged + release; pipeline halted for the recorded owner decision
- **Depends on**: 4
- **Status**: pending
## Milestone Releases
- **v0.1.4 = extended-prototype candidate** (superseded by the P5/P6 extension cycle)
- **v0.1.5 = app shell + research library + settings + avatar** (P5 patch)
+5 -2
View File
@@ -3,13 +3,13 @@
"projects": [],
"active_project": "researchx",
"milestone": "v0.3",
"milestone_name": "next-milestone-planning",
"milestone_name": "hosted",
"milestone_type": "feature",
"versioning": {
"major_locked": true,
"major_lock_note": "MAJOR remains 0 until an owner-approved v1 release; all pre-v1 ships are MINOR/PATCH only",
"tag_line": "v0.3.x",
"next_milestone": "v0.3 scope definition pending owner direction (post-MVP backlog: live-bound library/settings/suggestions/refine, backend hosting, remaining 6 methodologies)"
"next_milestone": "v0.4 candidates (owner-directed post-v0.3): remaining 6 methodologies (REQ-35..40), OAuth deferred from v0.3 auth, Exa/Perplexity search adapters, S3/git storage, memory scopes"
},
"autonomy": {
"level": "full",
@@ -51,6 +51,9 @@
],
"search": [
"TAVILY_API_KEY"
],
"keys": [
"RESEARCHX_KEK"
]
}
}
-157
View File
@@ -1,157 +0,0 @@
# .gitea/workflows/ci.yml - ResearchX phase-4 CI gate (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"]
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
-7
View File
@@ -1,12 +1,5 @@
# ResearchX
**TL;DR** — ResearchX turns one question into a cited research report: pick a method, approve the plan, and the engine visits real web sources, with every claim anchored to a quoted passage from a page it actually read. The engine milestone (v0.2.5) is shipped and owner-approved; hosting goes live next (v0.3, in progress).
- **What** — FastAPI backend (planning, search, extraction, verification, export gates) plus a Next.js frontend; the full test suite runs with zero API keys.
- **Why** — Readers can check every citation back to its source passage; runs are resumable from durable state.
- **Action** — no action — FYI. Developers: see the Quickstart below.
- **Details** — `frontend/README.md` (web app); `.ciagent/` (pipeline state and roadmap).
A deep-research studio where you bring your own model, storage, and memory; choose a research methodology before a single query fires; and every claim in the final report is anchored to a visited, verified, quotable source.
**Status: MVP shipped (v0.2.5, owner-approved 2026-09-27)** — the full research engine (FastAPI + asyncio state machine + adapter layer + attack-proven trust loop) behind the v0.1 frontend. Live mode covers the core research loop (G-002/G-009): the engine runs locally per the quickstart; library/settings/suggestions/refine stay mock-backed until v0.3+.
+2 -1
View File
@@ -2,4 +2,5 @@
__pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.mypy_cache/
var/
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"uvicorn>=0.30,<1",
"sse-starlette>=2.1,<3",
"httpx>=0.27,<1",
"argon2-cffi>=23.1,<26",
]
[dependency-groups]
@@ -5,19 +5,27 @@ import secrets as secrets_module
from pathlib import Path
from researchx.adapters.types import AdapterError
from researchx.config.ids import SLUG_RE, generate_slug
_RUN_ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"
_RUN_ID_RE = re.compile(r"^[a-z0-9]{8}$")
_ARTIFACT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def generate_run_id() -> str:
return "".join(secrets_module.choice(_RUN_ID_ALPHABET) for _ in range(8))
"""Generate an 8-char run id (consolidated generator — config/ids.py, P-14)."""
return generate_slug()
def _validate_slug(value: str, what: str) -> None:
if SLUG_RE.match(value) is None:
raise AdapterError(f"invalid {what}")
def _validate_run_id(run_id: str) -> None:
if not _RUN_ID_RE.match(run_id):
raise AdapterError("invalid run id")
_validate_slug(run_id, "run id")
def _validate_owner_id(owner_id: str) -> None:
_validate_slug(owner_id, "owner id")
def _validate_artifact_name(name: str) -> None:
@@ -26,12 +34,41 @@ def _validate_artifact_name(name: str) -> None:
class LocalStorageAdapter:
def __init__(self, base_dir: Path) -> None:
"""Owner-scoped local run storage (D-106): runs/<owner_id>/<run_id>/.
``owner_id`` is REQUIRED (an optional default invites silent
unpartitioned use — RESEARCH §5 pitfall) and validated against the
shared 8-char slug shape (D-105) so it is a filesystem-safe
partition key.
"""
def __init__(self, base_dir: Path, owner_id: str) -> None:
_validate_owner_id(owner_id)
self._base_dir = base_dir
self._owner_id = owner_id
@property
def owner_id(self) -> str:
return self._owner_id
@property
def base_dir(self) -> Path:
return self._base_dir
def run_dir(self, run_id: str) -> Path:
_validate_run_id(run_id)
return self._base_dir / "runs" / run_id
return self._base_dir / "runs" / self._owner_id / run_id
def list_run_ids(self) -> list[str]:
"""Scan this owner's partition, skipping non-directories/dotfiles."""
runs_dir = self._base_dir / "runs" / self._owner_id
if not runs_dir.is_dir():
return []
return sorted(
child.name
for child in runs_dir.iterdir()
if child.is_dir() and not child.name.startswith(".")
)
async def write_run_artifact(self, run_id: str, name: str, content: str) -> Path:
_validate_artifact_name(name)
@@ -58,7 +95,9 @@ class LocalStorageAdapter:
try:
return target.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise AdapterError(f"artifact not found: runs/{run_id}/{name}") from exc
raise AdapterError(
f"artifact not found: runs/{self._owner_id}/{run_id}/{name}"
) from exc
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path:
_validate_artifact_name(name)
@@ -8,4 +8,6 @@ class StorageAdapter(Protocol):
async def read_run_artifact(self, run_id: str, name: str) -> str: ...
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path: ...
def run_dir(self, run_id: str) -> Path: ...
+255 -94
View File
@@ -1,9 +1,9 @@
import asyncio
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from typing import Annotated, Any
from fastapi import FastAPI, HTTPException
from fastapi import APIRouter, Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, ValidationError
from sse_starlette.sse import EventSourceResponse
@@ -12,10 +12,17 @@ from researchx.adapters.capability_probe import probe_model_adapter, supports_pr
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import AdapterError, ChatResult
from researchx.auth.admin_routes import build_admin_router
from researchx.auth.guard import Guard, build_admin_guard, build_dev_guard, build_guard
from researchx.auth.models import UserPublic
from researchx.auth.routes import build_auth_router
from researchx.auth.sessions import SessionStore
from researchx.auth.users import UserStore
from researchx.config.paths import data_root
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus, RunEvent
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
from researchx.engine.states import TERMINAL_STATES, RunStateName
from researchx.methodologies import registry
_CANNED_PLAN = (
@@ -27,6 +34,8 @@ _CANNED_WRITER = (
"The verified core finding [p-1]. A second corroborated observation [p-2].\n"
)
_CORS_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]
class CreateRunBody(BaseModel):
question: str = Field(min_length=1, max_length=2000)
@@ -84,11 +93,8 @@ _ETA_TABLE = {"rapid-answer": (2, "low"), "deep-report": (12, "medium")}
def _cost_estimate(slug: str) -> str:
depth, breadth = 1, 1
methodology = registry.get(slug)
depth = methodology.dials.depth
breadth = methodology.dials.breadth
units = depth * breadth
units = methodology.dials.depth * methodology.dials.breadth
if units <= 1:
return "~5k tokens (estimate)"
if units <= 4:
@@ -104,33 +110,108 @@ def _why_recommended(slug: str) -> str:
class RunRegistry:
"""In-memory live-run registry, keyed (owner_id, run_id) (REQ-71).
Cross-owner access is indistinguishable from a missing run: the
keyed lookup misses, routes translate the miss to a 404. Single
uvicorn worker only — this state is single-process by design.
"""
def __init__(self) -> None:
self._actors: dict[str, tuple[RunActor, asyncio.Task[RunState]]] = {}
self._buses: dict[str, EventBus] = {}
self.cost_counters: dict[str, int] = {"runs_created": 0, "runs_completed": 0}
self._actors: dict[tuple[str, str], tuple[RunActor, asyncio.Task[RunState]]] = {}
self._buses: dict[tuple[str, str], EventBus] = {}
# Live SSE subscriber refcounts, keyed like _actors (P-6).
self._subscribers: dict[tuple[str, str], int] = {}
# Owner-keyed cost counters (P-13): per-user usage surface.
self.cost_counters: dict[str, dict[str, int]] = {}
def add(self, run_id: str, actor: RunActor, task: asyncio.Task[RunState], bus: EventBus) -> None:
self._actors[run_id] = (actor, task)
self._buses[run_id] = bus
self.cost_counters["runs_created"] += 1
def _counters(self, owner_id: str) -> dict[str, int]:
return self.cost_counters.setdefault(owner_id, {"runs_created": 0, "runs_completed": 0})
def actor(self, run_id: str) -> RunActor:
entry = self._actors.get(run_id)
def add(
self,
owner_id: str,
run_id: str,
actor: RunActor,
task: asyncio.Task[RunState],
bus: EventBus,
) -> None:
self._actors[(owner_id, run_id)] = (actor, task)
self._buses[(owner_id, run_id)] = bus
self._counters(owner_id)["runs_created"] += 1
def actor(self, owner_id: str, run_id: str) -> RunActor:
entry = self._actors.get((owner_id, run_id))
if entry is None:
raise KeyError(run_id)
raise KeyError((owner_id, run_id))
return entry[0]
def bus(self, run_id: str) -> EventBus:
bus = self._buses.get(run_id)
def bus(self, owner_id: str, run_id: str) -> EventBus:
bus = self._buses.get((owner_id, run_id))
if bus is None:
raise KeyError(run_id)
raise KeyError((owner_id, run_id))
return bus
def has(self, run_id: str) -> bool:
return run_id in self._actors
def has(self, owner_id: str, run_id: str) -> bool:
return (owner_id, run_id) in self._actors
def note_completed(self) -> None:
self.cost_counters["runs_completed"] += 1
def note_completed(self, owner_id: str) -> None:
self._counters(owner_id)["runs_completed"] += 1
def completed_snapshot(self, owner_id: str) -> dict[str, int]:
"""Current counters for *owner_id*, derived where possible.
``runs_completed`` is computed from the live registry (a run is
completed iff its actor sits in a terminal state) and merged
with the sticky counter — which keeps counting runs that have
since been pruned from the registry. Deriving from actor state
removes the scheduling race between the actor finishing and a
detached watcher task incrementing the counter.
"""
counters = dict(self._counters(owner_id))
live_done = sum(
1
for (owner, _), (actor, _) in self._actors.items()
if owner == owner_id and actor.state.state == RunStateName.DONE
)
if live_done:
# Prefer live truth when registry entries still exist; the
# sticky counter catches pruned/evicted runs afterwards.
counters["runs_completed"] = max(counters["runs_completed"], live_done)
return counters
def subscribe_events(self, owner_id: str, run_id: str) -> None:
self._subscribers[(owner_id, run_id)] = self._subscribers.get((owner_id, run_id), 0) + 1
def unsubscribe_events(self, owner_id: str, run_id: str) -> None:
count = self._subscribers.get((owner_id, run_id), 0)
if count <= 1:
self._subscribers.pop((owner_id, run_id), None)
else:
self._subscribers[(owner_id, run_id)] = count - 1
def subscriber_count(self, owner_id: str, run_id: str) -> int:
return self._subscribers.get((owner_id, run_id), 0)
def prune(self, owner_id: str, run_id: str) -> bool:
"""Drop a terminal entry with zero live SSE subscribers (P-6).
Terminal entries are otherwise a slow multi-user leak. Events
for a pruned run 404 afterwards — documented acceptable; the
trace persists on disk.
"""
entry = self._actors.get((owner_id, run_id))
if entry is None:
return False
actor = entry[0]
if actor.state.state not in TERMINAL_STATES:
return False
if self.subscriber_count(owner_id, run_id) > 0:
return False
self._actors.pop((owner_id, run_id), None)
self._buses.pop((owner_id, run_id), None)
self._subscribers.pop((owner_id, run_id), None)
return True
def create_app(
@@ -138,16 +219,23 @@ def create_app(
storage_dir: Path | None = None,
model_adapter: Any = None,
search_adapter: Any = None,
auth_enabled: bool = True,
cookie_secure: bool = False,
data_dir: Path | None = None,
) -> FastAPI:
app = FastAPI(title="ResearchX API", version="0.2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
"""Build the ResearchX API app.
storage = LocalStorageAdapter(base_dir=storage_dir or Path.cwd() / ".researchx")
``auth_enabled=False`` is a dev/test harness parameter ONLY — never
an env var readable in production (C3-16). It resolves a synthetic
dev admin for every request so zero-key suites keep working.
``data_dir`` overrides the auth-store location (defaults to
``data_root()``); ``storage_dir`` remains the run-artifact base.
"""
app = FastAPI(title="ResearchX API", version="0.2.0")
# D-107: run artifacts live under the data root (storage_dir
# overrides for tests); auth stores always share the same root.
storage_base = storage_dir if storage_dir is not None else data_root()
runs = RunRegistry()
model = model_adapter or FakeModelAdapter(
canned=[
@@ -157,8 +245,20 @@ def create_app(
)
search = search_adapter or FakeSearchAdapter(CORPUS)
def _summary(run_id: str) -> RunSummary:
actor = runs.actor(run_id)
auth_root = data_dir if data_dir is not None else data_root()
users = UserStore(auth_root / "users.json")
sessions = SessionStore(auth_root / "sessions.json")
guard: Guard = build_guard(users, sessions) if auth_enabled else build_dev_guard()
admin_guard = build_admin_guard(guard)
def _owner_storage(user: UserPublic) -> LocalStorageAdapter:
"""Owner-scoped storage, constructed per request from the auth
dependency (REQ-71): runs/<user_id>/<run_id>/."""
return LocalStorageAdapter(base_dir=storage_base, owner_id=user.user_id)
def _summary(user: UserPublic, run_id: str) -> RunSummary:
actor = runs.actor(user.user_id, run_id)
state = actor.state
return RunSummary(
id=run_id,
@@ -169,21 +269,41 @@ def create_app(
started_at=state.created_at.isoformat(),
)
@app.get("/api/config")
async def config_status() -> ConfigStatus:
# ------------------------------------------------------------------
# PUBLIC surface: /healthz outside /api (D-103 — plain GET for the
# TLS-front proxy, never auth-wrapped) + /api/auth/* (no
# require_user; the auth router enforces the CSRF header on its
# own POSTs).
# ------------------------------------------------------------------
public = build_auth_router(users, sessions, cookie_secure=cookie_secure)
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
# ------------------------------------------------------------------
# PROTECTED router: every /api business route carries the guard.
# Admin user management additionally carries the admin guard.
# ------------------------------------------------------------------
protected = APIRouter(dependencies=[Depends(guard)])
protected.include_router(build_admin_router(users, sessions, admin_guard))
@protected.get("/api/config")
async def config_status(user: Annotated[UserPublic, Depends(guard)]) -> ConfigStatus:
latest_probe = None
for run_id in list(runs._actors)[-1:]:
actor_probe = runs.actor(run_id).state.probe
latest_probe = actor_probe
for owner_and_run in list(runs._actors)[-1:]:
if owner_and_run[0] == user.user_id:
latest_probe = runs.actor(*owner_and_run).state.probe
return ConfigStatus(
model_adapter=type(model).__name__,
search_adapter=type(search).__name__,
storage_dir=str(storage.run_dir("00000000").parent),
storage_dir=str(storage_base),
probe=latest_probe,
cost_counters=runs.cost_counters,
cost_counters=runs.completed_snapshot(user.user_id),
)
@app.get("/api/methodologies")
@protected.get("/api/methodologies")
async def methodologies() -> list[MethodologyCard]:
cards: list[MethodologyCard] = []
for methodology in registry.list():
@@ -202,10 +322,11 @@ def create_app(
)
return cards
@app.post("/api/runs", status_code=201)
async def create_run(body: CreateRunBody) -> RunSummary:
@protected.post("/api/runs", status_code=201)
async def create_run(body: CreateRunBody, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
if body.methodology not in registry:
raise HTTPException(status_code=404, detail="unknown methodology")
storage = _owner_storage(user)
try:
run_id, config = make_run_config(
question=body.question,
@@ -213,6 +334,7 @@ def create_app(
methodology_slug=body.methodology,
overrides=body.dial_overrides,
registry=registry,
owner_id=user.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@@ -226,37 +348,43 @@ def create_app(
storage=storage,
registry=registry,
event_bus=bus,
on_complete=runs.note_completed,
)
probe_data: dict[str, Any] | None = None
# Stamp the owner on the persisted state (REQ-71). The actor
# itself is untouched — engine contracts unchanged.
actor.state.owner_id = user.user_id
if supports_probe(model):
try:
probe = await probe_model_adapter(model)
probe_data = probe.model_dump(mode="json")
await actor.attach_probe(probe_data)
await actor.attach_probe(probe.model_dump(mode="json"))
except AdapterError:
probe_data = None
pass
task = asyncio.create_task(actor.run())
runs.add(run_id, actor, task, bus)
runs.add(user.user_id, run_id, actor, task, bus)
async def _watch() -> None:
await task
state = await task
if state.state == RunStateName.DONE:
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:
@@ -264,38 +392,40 @@ def create_app(
except ValidationError:
raise HTTPException(status_code=422, detail="invalid plan")
await actor.approve(edited_plan)
return _summary(run_id)
return _summary(user, run_id)
@app.post("/api/runs/{run_id}/pause")
async def pause_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
@protected.post("/api/runs/{run_id}/pause")
async def pause_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
runs.actor(run_id).pause()
return _summary(run_id)
runs.actor(user.user_id, run_id).pause()
return _summary(user, run_id)
@app.post("/api/runs/{run_id}/resume")
async def resume_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
@protected.post("/api/runs/{run_id}/resume")
async def resume_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
runs.actor(run_id).resume()
return _summary(run_id)
runs.actor(user.user_id, run_id).resume()
return _summary(user, run_id)
@app.post("/api/runs/{run_id}/cancel")
async def cancel_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
@protected.post("/api/runs/{run_id}/cancel")
async def cancel_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
actor = runs.actor(run_id)
actor = runs.actor(user.user_id, run_id)
actor.cancel()
actor.resume()
return _summary(run_id)
return _summary(user, run_id)
@app.get("/api/runs/{run_id}/events")
async def run_events(run_id: str) -> EventSourceResponse:
if not runs.has(run_id):
@protected.get("/api/runs/{run_id}/events")
async def run_events(
run_id: str, user: Annotated[UserPublic, Depends(guard)]
) -> EventSourceResponse:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
bus = runs.bus(run_id)
actor = runs.actor(run_id)
terminal = {RunStateName.DONE, RunStateName.CANCELLED, RunStateName.FAILED}
bus = runs.bus(user.user_id, run_id)
actor = runs.actor(user.user_id, run_id)
terminal = TERMINAL_STATES
async def event_stream() -> AsyncIterator[dict[str, str]]:
queue: asyncio.Queue[RunEvent] = asyncio.Queue()
@@ -303,6 +433,7 @@ def create_app(
async def forward(event: RunEvent) -> None:
await queue.put(event)
runs.subscribe_events(user.user_id, run_id) # SSE refcount (P-6)
bus.subscribe(forward)
try:
replay = bus.replay()
@@ -324,27 +455,31 @@ def create_app(
return
finally:
bus.unsubscribe(forward)
runs.unsubscribe_events(user.user_id, run_id)
# Registry pruning (P-6): a terminal run whose last SSE
# subscriber just left is dropped from the registry.
# Events for a pruned run 404 afterwards — documented
# acceptable; the trace persists on disk.
runs.prune(user.user_id, run_id)
return EventSourceResponse(event_stream())
return EventSourceResponse(event_stream())
@app.get("/api/runs/{run_id}/report")
async def run_report(run_id: str) -> dict[str, Any]:
if not runs.has(run_id):
@protected.get("/api/runs/{run_id}/report")
async def run_report(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
try:
content = await storage.read_run_artifact(run_id, "report.md")
content = await _owner_storage(user).read_run_artifact(run_id, "report.md")
except AdapterError:
raise HTTPException(status_code=404, detail="report not ready")
return {"run_id": run_id, "markdown": content}
@app.get("/api/runs/{run_id}/ledger")
async def run_ledger(run_id: str) -> dict[str, Any]:
if not runs.has(run_id):
@protected.get("/api/runs/{run_id}/ledger")
async def run_ledger(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
if not runs.has(user.user_id, run_id):
raise HTTPException(status_code=404, detail="run not found")
try:
content = await storage.read_run_artifact(run_id, "ledger.json")
content = await _owner_storage(user).read_run_artifact(run_id, "ledger.json")
except AdapterError:
raise HTTPException(status_code=404, detail="ledger not ready")
import json
@@ -352,7 +487,33 @@ def create_app(
data: dict[str, Any] = json.loads(content)
return data
app.include_router(public)
app.include_router(protected)
# Startup sweep (D-115, G-101): every non-terminal state.json under
# the storage base is rewritten as CANCELLED — in-flight actors died
# with the previous process; rehydration is out of v0.3 scope. Runs
# synchronously at app construction so the very first request
# already sees a consistent disk.
from researchx.api.sweep import sweep_in_flight_runs
sweep_in_flight_runs(storage_base)
# CORS is added AFTER the routers so it is the OUTERMOST middleware
# (starlette builds the stack in add-order: last added = outermost)
# — preflight OPTIONS is answered without any auth 401 (P-4/C3-04).
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
return app
app = create_app()
# Dev-only module app (README's `uvicorn researchx.api.app:app`).
# Pitfall P-12/§8 item 3: this serves FAKES with default storage —
# the production entrypoint (researchx.api.main, Phase 2) must never
# import this global. Single worker only (in-memory stores by design).
app = create_app()
+6
View File
@@ -64,10 +64,16 @@ class EndlessModelAdapter:
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
# Dev/E2E bridge: auth explicitly OFF (P-5) — a loopback-only,
# disposable harness. Never the production entrypoint; the
# production server (researchx.api.main, Phase 2) never passes
# auth_enabled.
app = create_app(
storage_dir=Path(tmp),
model_adapter=EndlessModelAdapter(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=Path(tmp),
)
@app.on_event("startup")
+61
View File
@@ -0,0 +1,61 @@
"""Startup sweep (D-115, G-101).
On app construction, every non-terminal ``state.json`` under the
storage base is rewritten as CANCELLED — in-flight actors died with the
previous process; rehydration is out of v0.3 scope. CANCELLED is legal
from every non-terminal state (states.py — including PLANNING per
G-101, pinned by test). The registry-side pruning lives in app.py
(RunRegistry.prune, P-6).
"""
from pathlib import Path
from researchx.engine.run_state import RunState
from researchx.engine.states import TERMINAL_STATES, RunStateName
def _atomic_write(path: Path, content: str) -> None:
import os
import secrets
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
tmp_path.replace(path)
def sweep_in_flight_runs(base_dir: Path) -> list[tuple[str, str]]:
"""Rewrite every non-terminal state.json under base_dir as CANCELLED.
Scans ``base_dir/runs/<owner_id>/<run_id>/state.json`` (the
partitioned layout, D-106). Returns the swept (owner_id, run_id)
pairs. Corrupt state files are skipped defensively — a sweep must
never prevent startup.
"""
swept: list[tuple[str, str]] = []
runs_dir = base_dir / "runs"
if not runs_dir.is_dir():
return swept
for owner_dir in sorted(runs_dir.iterdir()):
if not owner_dir.is_dir() or owner_dir.name.startswith("."):
continue
for run_dir in sorted(owner_dir.iterdir()):
if not run_dir.is_dir() or run_dir.name.startswith("."):
continue
state_path = run_dir / "state.json"
if not state_path.is_file():
continue
try:
state = RunState.model_validate_json(state_path.read_text(encoding="utf-8"))
except (ValueError, OSError):
# Corrupt/unreadable state files are not swept, never
# fatal — a sweep must never prevent startup.
continue
if state.state in TERMINAL_STATES:
continue
state.state = RunStateName.CANCELLED # legal from every non-terminal state (G-101)
_atomic_write(state_path, state.model_dump_json())
swept.append((owner_dir.name, run_dir.name))
return swept
+24
View File
@@ -0,0 +1,24 @@
"""Auth package: user accounts, sessions, guard, and routes (REQ-69/70/71)."""
from researchx.auth.models import (
AdminCreateUserBody,
LoginBody,
RegisterBody,
UserPublic,
to_public,
)
from researchx.auth.sessions import Session, SessionStore
from researchx.auth.users import User, UserError, UserStore
__all__ = [
"AdminCreateUserBody",
"LoginBody",
"RegisterBody",
"Session",
"SessionStore",
"User",
"UserError",
"UserPublic",
"UserStore",
"to_public",
]
@@ -0,0 +1,56 @@
"""Admin user-management routes (T-1.5, REQ-70).
Mounted on the PROTECTED router (session + CSRF already enforced by
``require_user``); every route additionally requires the admin role.
Responses expose ``UserPublic`` only — never a password hash. Account
creation enforces the same >=12 password policy as registration;
deactivation sets ``active=False`` and immediately revokes every
session of the target user (data is retained).
"""
from fastapi import APIRouter, Depends, HTTPException
from researchx.auth.guard import Guard
from researchx.auth.models import AdminCreateUserBody, UserPublic, to_public
from researchx.auth.sessions import SessionStore
from researchx.auth.users import UserError, UserStore
def build_admin_router(
users: UserStore,
sessions: SessionStore,
admin_guard: Guard,
) -> APIRouter:
"""Build the admin user surface (mounted at /api/admin, admin-only)."""
router = APIRouter(
prefix="/api/admin",
tags=["admin"],
dependencies=[Depends(admin_guard)],
)
@router.get("/users", response_model=list[UserPublic])
async def list_users() -> list[UserPublic]:
return [to_public(u) for u in users.list()]
@router.post("/users", status_code=201, response_model=UserPublic)
async def create_user(body: AdminCreateUserBody) -> UserPublic:
try:
user = await users.create_user(
email=body.email,
password=body.password,
display_name=body.display_name or body.email.split("@")[0],
role=body.role,
)
except UserError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return to_public(user)
@router.post("/users/{user_id}/deactivate", response_model=UserPublic)
async def deactivate_user(user_id: str) -> UserPublic:
updated = users.deactivate(user_id)
if updated is None:
raise HTTPException(status_code=404, detail="user not found")
sessions.revoke_all_for(user_id) # deactivation kills sessions immediately
return to_public(updated)
return router
+109
View File
@@ -0,0 +1,109 @@
"""Auth guard — session + CSRF enforcement as route dependencies (T-1.4).
Deliberately NOT middleware: router dependencies appear per-route, are
overridable via ``app.dependency_overrides``, and never wrap SSE
response bodies (no BaseHTTPMiddleware near the event stream).
Enforcement order:
1. missing/invalid/expired session -> 401 "authentication required"
2. non-GET without the CSRF header -> 403 "missing request header"
3. missing or deactivated user -> 401 "account disabled"
then the sliding-expiry ``touch()``.
When auth is disabled (dev/test harness only — a create_app parameter,
never an env var readable in production, C3-16), the dev guard
resolves a synthetic admin user for every request so all routes behave
exactly as authenticated.
"""
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from fastapi import HTTPException, Request
from researchx.auth.models import UserPublic, to_public
from researchx.auth.sessions import Session, SessionStore
from researchx.auth.users import User, UserStore
SESSION_COOKIE = "researchx_session"
CSRF_HEADER = "x-researchx-request"
DEV_USER_ID = "dev00000"
Guard = Callable[[Request], Awaitable[UserPublic]]
def _dev_user() -> User:
"""The synthetic admin every request resolves when auth is disabled."""
return User(
user_id=DEV_USER_ID,
email="dev@localhost",
password_hash="", # never persisted, never verified
display_name="Dev User",
role="admin",
active=True,
created_at=datetime.now(UTC),
)
def require_csrf_header(request: Request) -> None:
"""CSRF defense for public POSTs (login/register) — same rule as the guard."""
if request.method != "GET" and CSRF_HEADER not in request.headers:
raise HTTPException(status_code=403, detail="missing request header")
def build_guard(users: UserStore, sessions: SessionStore) -> Guard:
"""Build the authenticated-mode require_user dependency."""
async def require_user(request: Request) -> UserPublic:
token = request.cookies.get(SESSION_COOKIE)
session: Session | None = sessions.get(token) if token else None
if session is None:
raise HTTPException(status_code=401, detail="authentication required")
if request.method != "GET" and CSRF_HEADER not in request.headers:
raise HTTPException(status_code=403, detail="missing request header")
user: User | None = users.get(session.user_id)
if user is None or not user.active:
raise HTTPException(status_code=401, detail="account disabled")
sessions.touch(session) # sliding expiry
return to_public(user)
return require_user
def build_dev_guard() -> Guard:
"""Build the dev-mode guard: every request resolves the synthetic dev user."""
async def require_user(request: Request) -> UserPublic:
del request # dev mode: no session, no CSRF — routes behave as before
return to_public(_dev_user())
return require_user
def build_admin_guard(guard: Guard) -> Guard:
"""Wrap a guard with the admin role check (REQ-70).
The returned dependency resolves the wrapped guard first, so
``app.dependency_overrides`` on either composes correctly.
"""
async def require_admin(request: Request) -> UserPublic:
user = await guard(request)
if user.role != "admin":
raise HTTPException(status_code=403, detail="admin role required")
return user
return require_admin
__all__ = [
"CSRF_HEADER",
"DEV_USER_ID",
"SESSION_COOKIE",
"Guard",
"build_admin_guard",
"build_dev_guard",
"build_guard",
"require_csrf_header",
]
+60
View File
@@ -0,0 +1,60 @@
"""Auth API request/response contracts (T-1.3).
These are the wire models for the register/login/logout/me routes and
the admin user surface. Password hashes never appear in any of them
(REQ-69: no password-hash material in any API response).
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from researchx.auth.users import MIN_PASSWORD_LENGTH, User
# Lightweight email shape check — the auth surface needs a stable,
# normalizable identifier, not a full RFC 5322 validation (and pulling
# pydantic[email] would add an unplanned dependency).
_EMAIL_RE = r"^[^\s@]+@[^\s@]+\.[^\s@]+$"
class RegisterBody(BaseModel):
email: str = Field(pattern=_EMAIL_RE, max_length=254)
password: str = Field(min_length=MIN_PASSWORD_LENGTH)
display_name: str = Field(default="", max_length=120)
class LoginBody(BaseModel):
email: str = Field(pattern=_EMAIL_RE, max_length=254)
password: str = Field(min_length=1)
class AdminCreateUserBody(BaseModel):
email: str = Field(pattern=_EMAIL_RE, max_length=254)
password: str = Field(min_length=MIN_PASSWORD_LENGTH)
display_name: str = Field(default="", max_length=120)
role: str = Field(default="user", pattern=r"^(admin|user)$")
class UserPublic(BaseModel):
"""A user as exposed on the wire — no password_hash, ever."""
model_config = ConfigDict(from_attributes=True)
user_id: str
email: str
display_name: str
role: str
active: bool
created_at: datetime
def to_public(user: User) -> UserPublic:
"""Project a stored ``User`` to its public wire shape."""
return UserPublic(
user_id=user.user_id,
email=user.email,
display_name=user.display_name,
role=user.role,
active=user.active,
created_at=user.created_at,
)
+119
View File
@@ -0,0 +1,119 @@
"""Auth routes: register / login / logout / me (T-1.4, REQ-69/70).
Mounted on a PUBLIC router at /api/auth/* (no require_user dependency)
— but login/register POSTs still enforce the CSRF header (C3-05) via
``Depends(require_csrf_header)``.
Route semantics:
- register: open only while the user store is empty; the first user
becomes admin, everything else 403s. The only await between the
emptiness check and the insert is the argon2 hash; re-checking under
the store lock keeps it race-free (D-104, single worker by design).
- login: uniform 401 "invalid email or password" for every failure
mode (no user enumeration); a fresh token is issued every login
(session-fixation defense by rotation); the httpOnly cookie is set
with SameSite=Lax, Path=/, no Domain attribute, Secure when
configured for TLS-fronted hosts.
- logout: revokes the presented token immediately.
- me: the current user (session only — no CSRF header on GETs).
"""
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from researchx.auth.guard import SESSION_COOKIE, require_csrf_header
from researchx.auth.models import LoginBody, RegisterBody, UserPublic, to_public
from researchx.auth.sessions import SessionStore
from researchx.auth.users import UserError, UserStore
_COOKIE_MAX_AGE = 14 * 24 * 3600
def build_auth_router(
users: UserStore,
sessions: SessionStore,
cookie_secure: bool,
) -> APIRouter:
"""Build the public auth router (mounted at /api/auth)."""
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=SESSION_COOKIE,
value=token,
max_age=_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
path="/",
secure=cookie_secure,
)
def _presented_token(request: Request) -> str | None:
return request.cookies.get(SESSION_COOKIE)
@router.post(
"/register",
status_code=201,
response_model=UserPublic,
dependencies=[Depends(require_csrf_header)],
)
async def register(body: RegisterBody) -> UserPublic:
# Fast-path 403 for the common case; the authoritative
# emptiness re-check happens under the store lock inside
# create_first_admin (D-104) so a concurrent duplicate
# register cannot both succeed.
if not users.empty():
raise HTTPException(status_code=403, detail="registration closed")
try:
user = await users.create_first_admin(
email=body.email,
password=body.password,
display_name=body.display_name or body.email.split("@")[0],
)
except UserError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
return to_public(user)
@router.post(
"/login",
response_model=UserPublic,
dependencies=[Depends(require_csrf_header)],
)
async def login(body: LoginBody, response: Response) -> UserPublic:
user = users.get_by_email(body.email)
if user is None or not await users.verify_password(user, body.password):
raise HTTPException(status_code=401, detail="invalid email or password")
if not user.active:
raise HTTPException(status_code=401, detail="invalid email or password")
# Token rotation: a fresh token on every login kills session
# fixation by construction (D-101).
token = sessions.generate_token()
sessions.create(token, user.user_id)
_set_session_cookie(response, token)
if await users.needs_rehash(user):
await users.rehash_password(user, body.password)
return to_public(user)
@router.post(
"/logout",
dependencies=[Depends(require_csrf_header)],
)
async def logout(request: Request, response: Response) -> dict[str, bool]:
token = _presented_token(request)
if token:
sessions.revoke(token)
response.delete_cookie(key=SESSION_COOKIE, path="/")
return {"ok": True}
@router.get("/me", response_model=UserPublic)
async def me(request: Request) -> UserPublic:
token = _presented_token(request)
session = sessions.get(token) if token else None
if session is None:
raise HTTPException(status_code=401, detail="authentication required")
user = users.get(session.user_id)
if user is None or not user.active:
raise HTTPException(status_code=401, detail="authentication required")
sessions.touch(session)
return to_public(user)
return router
+161
View File
@@ -0,0 +1,161 @@
"""Session store — server-side, opaque tokens, durable JSON mirror (D-101).
Tokens are ``secrets.token_urlsafe(32)``; only their sha256 hash is
stored (in memory and in ``<data_root>/sessions.json``). Expiry is
14-day sliding: every authenticated request calls ``touch()`` which
pushes ``expires_at`` forward, persisting at most once per 60s.
Revocation is immediate (logout deletes the presented token;
deactivation deletes every token of the user).
"""
import asyncio
import hashlib
import json
import os
import secrets
import threading
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field, ValidationError
SESSION_TTL_SECONDS = 14 * 24 * 3600
_PERSIST_THROTTLE_SECONDS = 60.0
class Session(BaseModel):
token_hash: str
user_id: str
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
last_seen: datetime = Field(default_factory=lambda: datetime.now(UTC))
expires_at: datetime
model_config = ConfigDict(frozen=True)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
class SessionStore:
"""In-memory map keyed by token_hash + durable sessions.json mirror."""
def __init__(self, path: Path) -> None:
self._path = path
self._sessions: dict[str, Session] = {}
self._lock = threading.Lock()
self._last_persist = 0.0
self._load()
# ------------------------------------------------------------ persistence
def _load(self) -> None:
if not self._path.exists():
return
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return
for record in raw.get("sessions", []):
try:
session = Session.model_validate(record)
except (ValidationError, KeyError, TypeError):
continue # defensive: skip corrupt records on load
self._sessions[session.token_hash] = session
def _persist_locked(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"sessions": [s.model_dump(mode="json") for s in self._sessions.values()]
}
tmp_path = self._path.with_name(f"{self._path.name}.{secrets.token_hex(4)}.tmp")
content = json.dumps(payload, indent=2)
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, self._path)
self._last_persist = time.monotonic()
def _persist(self) -> None:
with self._lock:
self._persist_locked()
# ------------------------------------------------------------- queries
def get(self, token: str) -> Session | None:
"""Resolve a presented token; expired or unknown tokens yield None."""
token_hash = _hash_token(token)
session = self._sessions.get(token_hash)
if session is None:
return None
if session.expires_at <= datetime.now(UTC):
with self._lock:
expired = self._sessions.pop(token_hash, None)
if expired is not None:
self._persist_locked()
return None
return session
# ------------------------------------------------------------- mutation
def create(self, token: str, user_id: str) -> Session:
"""Store a new session for *token* (only its hash is kept)."""
now = datetime.now(UTC)
session = Session(
token_hash=_hash_token(token),
user_id=user_id,
created_at=now,
last_seen=now,
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
)
with self._lock:
self._sessions[session.token_hash] = session
self._persist_locked()
return session
def touch(self, session: Session) -> None:
"""Sliding expiry: push expires_at forward, throttled persist."""
now = datetime.now(UTC)
renewed = Session(
token_hash=session.token_hash,
user_id=session.user_id,
created_at=session.created_at,
last_seen=now,
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
)
with self._lock:
current = self._sessions.get(session.token_hash)
if current is None or current != session:
return # stale or raced touch — nothing to extend
self._sessions[renewed.token_hash] = renewed
if time.monotonic() - self._last_persist > _PERSIST_THROTTLE_SECONDS:
self._persist_locked()
def revoke(self, token: str) -> None:
"""Immediately delete the session for *token* (logout)."""
token_hash = _hash_token(token)
with self._lock:
if self._sessions.pop(token_hash, None) is not None:
self._persist_locked()
def revoke_all_for(self, user_id: str) -> None:
"""Immediately delete every session of *user_id* (deactivation)."""
with self._lock:
doomed = [h for h, s in self._sessions.items() if s.user_id == user_id]
for token_hash in doomed:
del self._sessions[token_hash]
if doomed:
self._persist_locked()
def generate_token(self) -> str:
return secrets.token_urlsafe(32)
# ----------------------------------------------------------- asyncio shim
async def arevoke(self, token: str) -> None:
await asyncio.to_thread(self.revoke, token)
async def arevoke_all_for(self, user_id: str) -> None:
await asyncio.to_thread(self.revoke_all_for, user_id)
+203
View File
@@ -0,0 +1,203 @@
"""User store — durable JSON-backed account registry (REQ-69, D-102/D-105).
Persisted to ``<data_root>/users.json`` with the atomic tmp+``os.replace``
idiom (D-017). Password hashing is argon2id via argon2-cffi
``PasswordHasher()`` defaults; every hash/verify runs through
``asyncio.to_thread`` because argon2 verify at default parameters costs
~50-100ms of CPU and must leave the event loop alone.
"""
import asyncio
import json
import os
import secrets
import threading
from datetime import UTC, datetime
from pathlib import Path
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from researchx.config.ids import generate_slug
MIN_PASSWORD_LENGTH = 12
class UserError(ValueError):
"""Raised for user-store policy violations (duplicate email, weak password)."""
class User(BaseModel):
user_id: str = Field(pattern=r"^[a-z0-9]{8}$")
email: str
password_hash: str
display_name: str
role: str = "user"
active: bool = True
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
model_config = ConfigDict(frozen=True)
class UserStore:
"""In-memory map keyed by user_id + durable users.json mirror."""
def __init__(self, path: Path) -> None:
self._path = path
self._by_id: dict[str, User] = {}
self._by_email: dict[str, User] = {}
self._lock = threading.Lock()
self._hasher = PasswordHasher()
self._load()
# ------------------------------------------------------------ persistence
def _load(self) -> None:
if not self._path.exists():
return
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
# FAIL CLOSED on a corrupt user store. Swallowing it would
# silently report an empty store, reopening registration on
# top of existing accounts — the next persist would then
# overwrite them (auth-reset-by-corruption). The file is
# renamed aside so an operator can recover it; startup is
# refused rather than silently degrading.
salvage = self._path.with_name(f"{self._path.name}.corrupt.{secrets.token_hex(4)}")
os.replace(self._path, salvage)
raise RuntimeError(
f"corrupt user store {self._path}; moved aside to {salvage}"
) from exc
for record in raw.get("users", []):
try:
user = User.model_validate(record)
except (ValidationError, KeyError, TypeError):
continue # defensive: skip corrupt records on load
self._by_id[user.user_id] = user
self._by_email[user.email] = user
def _persist_locked(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {"users": [u.model_dump(mode="json") for u in self._by_id.values()]}
tmp_path = self._path.with_name(f"{self._path.name}.{secrets.token_hex(4)}.tmp")
content = json.dumps(payload, indent=2)
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, self._path)
def _persist(self) -> None:
with self._lock:
self._persist_locked()
# ------------------------------------------------------------- hashing
async def hash_password(self, password: str) -> str:
return await asyncio.to_thread(self._hasher.hash, password)
async def verify_password(self, user: User, password: str) -> bool:
try:
await asyncio.to_thread(self._hasher.verify, user.password_hash, password)
except VerifyMismatchError:
return False
return True
async def needs_rehash(self, user: User) -> bool:
return await asyncio.to_thread(self._hasher.check_needs_rehash, user.password_hash)
# ------------------------------------------------------------- queries
def empty(self) -> bool:
return not self._by_id
def get(self, user_id: str) -> User | None:
return self._by_id.get(user_id)
def get_by_email(self, email: str) -> User | None:
return self._by_email.get(email.casefold())
def list(self) -> list[User]:
return sorted(self._by_id.values(), key=lambda u: (u.created_at, u.user_id))
# ------------------------------------------------------------- mutation
async def create_user(
self,
*,
email: str,
password: str,
display_name: str,
role: str = "user",
) -> User:
normalized = email.casefold()
if len(password) < MIN_PASSWORD_LENGTH:
raise UserError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
if self._by_email.get(normalized) is not None:
raise UserError("email already registered")
password_hash = await self.hash_password(password)
user = User(
user_id=generate_slug(),
email=normalized,
password_hash=password_hash,
display_name=display_name,
role=role,
)
with self._lock:
if self._by_email.get(normalized) is not None:
raise UserError("email already registered")
self._by_id[user.user_id] = user
self._by_email[user.email] = user
self._persist_locked()
return user
async def rehash_password(self, user: User, password: str) -> None:
"""Rehash a user's password in place (login-time rehash on parameter drift)."""
password_hash = await self.hash_password(password)
with self._lock:
updated = user.model_copy(update={"password_hash": password_hash})
self._by_id[updated.user_id] = updated
self._by_email[updated.email] = updated
self._persist_locked()
async def create_first_admin(self, *, email: str, password: str, display_name: str) -> User:
"""Atomically create the first user, who is admin by construction.
The emptiness check + insert both happen under the store lock;
the only await (the argon2 hash) precedes the check. Combined
with the single uvicorn worker this makes registration open
exactly once (D-104).
"""
if len(password) < MIN_PASSWORD_LENGTH:
raise UserError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
normalized = email.casefold()
password_hash = await self.hash_password(password) # the one allowed await
with self._lock:
if self._by_id:
raise UserError("registration closed")
if self._by_email.get(normalized) is not None:
raise UserError("email already registered")
user = User(
user_id=generate_slug(),
email=normalized,
password_hash=password_hash,
display_name=display_name,
role="admin",
)
self._by_id[user.user_id] = user
self._by_email[user.email] = user
self._persist_locked()
return user
def deactivate(self, user_id: str) -> User | None:
with self._lock:
user = self._by_id.get(user_id)
if user is None:
return None
updated = user.model_copy(update={"active": False})
self._by_id[user_id] = updated
self._by_email[updated.email] = updated
self._persist_locked()
return updated
+25
View File
@@ -0,0 +1,25 @@
"""8-char slug ids — the single generator + validation (P-14 consolidation).
Run ids and user ids share one alphabet and one shape: 8 lowercase
alphanumeric characters, validated by ``^[a-z0-9]{8}$`` (D-105 — the
user_id doubles as a filesystem-safe partition key). Both
``adapters.local_storage`` and ``config.run_config`` import from here so
there is exactly one generator left in the tree.
"""
import re
import secrets
ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"
SLUG_RE = re.compile(r"^[a-z0-9]{8}$")
def generate_slug() -> str:
"""Generate a random 8-char slug from the shared alphabet."""
return "".join(secrets.choice(ALPHABET) for _ in range(8))
def is_valid_slug(value: str) -> bool:
"""Return True when *value* is a well-formed 8-char slug."""
return SLUG_RE.match(value) is not None
+31
View File
@@ -0,0 +1,31 @@
"""Data-root resolution (D-107).
All mutable, host-persisted state (users.json, sessions.json, runs/,
keys/, settings/) lives under one directory:
- ``RESEARCHX_DATA_DIR`` when set (container: ``/var/lib/researchx``),
- else ``backend/var/researchx`` relative to the repository root (dev).
Replaces the cwd-dependent ``Path.cwd() / ".researchx"`` storage default.
"""
import os
from pathlib import Path
_ENV_VAR = "RESEARCHX_DATA_DIR"
# src/researchx/config/paths.py -> repo root is four parents up.
_REPO_ROOT = Path(__file__).resolve().parents[4]
_DEV_DEFAULT = _REPO_ROOT / "backend" / "var" / "researchx"
def data_root() -> Path:
"""Return the mutable data root, creating it on demand.
The directory (and its parent chain) is created lazily so that
importing this module never touches the filesystem.
"""
raw = os.environ.get(_ENV_VAR)
root = Path(raw).expanduser() if raw else _DEV_DEFAULT
root.mkdir(parents=True, exist_ok=True)
return root
+6 -2
View File
@@ -1,4 +1,3 @@
import secrets
from collections.abc import Mapping
from datetime import datetime
from typing import Any
@@ -6,6 +5,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from researchx.config.dials import Dials
from researchx.config.ids import generate_slug
from researchx.methodologies.registry import MethodologyRegistry
@@ -23,6 +23,9 @@ class RunConfig(BaseModel, frozen=True):
resolved_dials: Dials
created_at: datetime
overrides: dict[str, Any] = Field(default_factory=dict)
# Owning user (REQ-71). Optional = serialization-compatible with
# pre-v0.3 config files (D-106: no migration).
owner_id: str | None = None
model_config = ConfigDict(frozen=True)
@@ -43,7 +46,8 @@ def resolve_dials(
def new_run_id() -> str:
return secrets.token_hex(4)
"""Generate a run id (P-14: single shared generator — config/ids.py)."""
return generate_slug()
__all__ = ["RunConfig", "UnknownOverrideError", "new_run_id", "resolve_dials"]
+5 -5
View File
@@ -1,5 +1,4 @@
import asyncio
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
@@ -35,7 +34,6 @@ class RunActor:
storage: LocalStorageAdapter,
registry: MethodologyRegistry,
event_bus: EventBus,
on_complete: Callable[[], None] | None = None,
) -> None:
self._config = run_config
self._model = model
@@ -43,7 +41,6 @@ class RunActor:
self._storage = storage
self._registry = registry
self._bus = event_bus
self._on_complete = on_complete
self._store = EvidenceStore()
self.state = RunState(
run_id=run_config.run_id,
@@ -86,8 +83,6 @@ class RunActor:
async def _checkpoint(self, target: RunStateName) -> None:
if self.state.state != target:
self.state.transition(target)
if target is RunStateName.DONE and self._on_complete is not None:
self._on_complete()
await self._persist()
async def _wait_if_paused(self) -> None:
@@ -356,7 +351,11 @@ def make_run_config(
overrides: dict[str, object],
registry: MethodologyRegistry,
run_id: str | None = None,
owner_id: str | None = None,
) -> tuple[str, RunConfig]:
"""Build a run config; ``owner_id`` (when given) travels with the
config and is stamped on the run state by the wiring layer (T-1.7).
"""
from researchx.adapters.local_storage import generate_run_id
rid = run_id or generate_run_id()
@@ -369,5 +368,6 @@ def make_run_config(
resolved_dials=dials,
overrides=dict(overrides),
created_at=datetime.now(UTC),
owner_id=owner_id,
)
return rid, config
+3 -1
View File
@@ -13,12 +13,14 @@ from researchx.methodologies import registry
async def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
owner_id = "demo0001" # 8-char owner slug (P-9: demo passes the owner)
run_id, config = make_run_config(
question="How do verification pipelines keep citations honest?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
owner_id=owner_id,
)
plan = (
'{"sub_questions": ["verification passes", "quote anchoring"], '
@@ -36,7 +38,7 @@ async def main() -> None:
]
)
search = FakeSearchAdapter(CORPUS)
storage = LocalStorageAdapter(base_dir=base)
storage = LocalStorageAdapter(base_dir=base, owner_id=owner_id)
bus = EventBus()
actor = RunActor(
run_config=config, model=model, search=search, storage=storage, registry=registry,
@@ -18,6 +18,9 @@ class RunState(BaseModel):
context: str = ""
methodology_slug: str
state: RunStateName = RunStateName.PLANNING
# Owner of the run (REQ-71). None on pre-v0.3 state files — old
# artifacts must deserialize unchanged (D-106: no migration).
owner_id: str | None = None
plan: Plan | None = None
depth_cursor: int = 0
depth_target: int = 1
+8 -1
View File
@@ -15,7 +15,14 @@ class RunStateName(StrEnum):
_LEGAL_TRANSITIONS: dict[RunStateName, set[RunStateName]] = {
RunStateName.PLANNING: {RunStateName.AWAITING_APPROVAL, RunStateName.FAILED},
# G-101: PLANNING -> CANCELLED is legal — the restart sweep rewrites
# in-flight PLANNING runs as CANCELLED; semantically a run that has
# not produced a plan can always be abandoned.
RunStateName.PLANNING: {
RunStateName.AWAITING_APPROVAL,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.AWAITING_APPROVAL: {
RunStateName.SEARCHING,
RunStateName.CANCELLED,
+22
View File
@@ -0,0 +1,22 @@
"""Shared test fixtures."""
import pytest
@pytest.fixture(autouse=True)
def _reset_sse_starlette_loop_binding():
"""Reset sse_starlette's module-global shutdown Event before each test.
``sse_starlette.AppStatus.should_exit_event`` is an ``anyio.Event``
memoized at the first SSE response and bound to that event loop.
pytest-asyncio gives every test a fresh loop, so a second SSE test in
one process would die with "Event is bound to a different event
loop". Resetting the global before each test keeps the library's
loop-binding per-test. (Production is unaffected: single process,
single loop, single uvicorn worker by design.)
"""
from sse_starlette.sse import AppStatus
AppStatus.should_exit = False
AppStatus.should_exit_event = None
yield
+4
View File
@@ -137,6 +137,8 @@ async def test_attack_pause_resume_mid_depth_loop_over_http(tmp_path: Path) -> N
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(canned=canned),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
@@ -196,6 +198,8 @@ async def test_attack_cancel_mid_loop_persists(tmp_path: Path) -> None:
+ [ChatResult(content="# never reached", model="fake-writer")]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
+4
View File
@@ -15,6 +15,8 @@ _WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
def _app(tmp_path: Path):
# Zero-key dev harness (C3-16): auth explicitly off — the synthetic
# dev admin resolves for every request.
return create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
@@ -24,6 +26,8 @@ def _app(tmp_path: Path):
]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
+590
View File
@@ -0,0 +1,590 @@
"""Auth route suite (T-1.9 backend half, REQ-69/70 — RESEARCH §11 list).
Full adversarial surface of the auth layer, run with
``auth_enabled=True``: first-user-admin, registration closing, uniform
login failures, session/logout semantics, CSRF enforcement, admin
create/deactivate (deactivation kills sessions), and the
no-password-hash-anywhere guarantee.
"""
import asyncio
import json
from pathlib import Path
import httpx
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.types import ChatResult
from researchx.api.app import create_app
from researchx.auth.guard import CSRF_HEADER
_PLAN = json.dumps(
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
)
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
_ADMIN_EMAIL = "admin@example.org"
_ADMIN_PASSWORD = "correct-horse-battery"
_SECOND_EMAIL = "second@example.org"
_SECOND_PASSWORD = "another-long-password"
_HEADERS = {CSRF_HEADER: "1"}
def _app(tmp_path: Path):
return create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER, model="fake-writer"),
]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
)
async def _client(tmp_path: Path) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=_app(tmp_path)),
base_url="http://testserver",
)
async def _register_first_admin(client: httpx.AsyncClient) -> httpx.Response:
return await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
headers=_HEADERS,
)
async def _login(client: httpx.AsyncClient, email: str, password: str) -> httpx.Response:
return await client.post(
"/api/auth/login", json={"email": email, "password": password}, headers=_HEADERS
)
# ------------------------------------------------------------------ register
async def test_healthz_outside_api_is_public(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
async def test_first_register_creates_admin(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await _register_first_admin(client)
assert response.status_code == 201
body = response.json()
assert body["role"] == "admin"
assert body["email"] == _ADMIN_EMAIL
assert body["active"] is True
assert len(body["user_id"]) == 8
assert "password_hash" not in body
assert "password" not in body
async def test_second_register_is_403(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
response = await client.post(
"/api/auth/register",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
assert response.status_code == 403
assert response.json()["detail"] == "registration closed"
async def test_register_rejects_short_password(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": "short"},
headers=_HEADERS,
)
assert response.status_code == 422 # pydantic contract rejects < 12
async def test_register_requires_csrf_header(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.post(
"/api/auth/register",
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
)
assert response.status_code == 403
assert response.json()["detail"] == "missing request header"
async def test_concurrent_register_yields_exactly_one_admin(tmp_path: Path) -> None:
"""D-104 adversarial pin: a simultaneous register storm on a fresh
store must produce exactly one 201/admin — the emptiness check +
insert are atomic under the store lock (create_first_admin), so a
raced duplicate cannot both win. Regression guard for the
create_user-based route that let N concurrent registers through."""
racers = [(f"racer{i}@example.org", f"racing-password-{i:02d}") for i in range(8)]
async with await _client(tmp_path) as client:
responses = await asyncio.gather(
*(
client.post(
"/api/auth/register",
json={"email": email, "password": password},
headers=_HEADERS,
)
for email, password in racers
)
)
winners = [(r, racer) for r, racer in zip(responses, racers, strict=True) if r.status_code == 201]
losers = [r for r in responses if r.status_code == 403]
assert len(winners) == 1, "exactly one register may win the race"
assert len(losers) == 7
assert winners[0][0].json()["role"] == "admin"
# The winner can log in; every losing racer has no account.
_, (winner_email, winner_password) = winners[0]
login = await client.post(
"/api/auth/login",
json={"email": winner_email, "password": winner_password},
headers=_HEADERS,
)
assert login.status_code == 200
for email, password in racers:
if email == winner_email:
continue
denied = await client.post(
"/api/auth/login",
json={"email": email, "password": password},
headers=_HEADERS,
)
assert denied.status_code == 401, "losing racer must have no account"
# -------------------------------------------------------------------- login
async def test_login_sets_httponly_cookie(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
response = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
assert response.status_code == 200
assert response.json()["role"] == "admin"
set_cookie = response.headers["set-cookie"]
assert "researchx_session=" in set_cookie
assert "httponly" in set_cookie.lower()
assert "samesite=lax" in set_cookie.lower()
assert "path=/" in set_cookie.lower()
assert "domain=" not in set_cookie.lower().replace("samesite=lax", "")
assert "secure" not in set_cookie.lower() # cookie_secure=False in tests
async def test_login_wrong_password_uniform_401(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
for email, password in [
(_ADMIN_EMAIL, "wrong-password-here"),
("ghost@example.org", _ADMIN_PASSWORD),
]:
response = await _login(client, email, password)
assert response.status_code == 401
assert response.json()["detail"] == "invalid email or password"
async def test_login_token_rotation(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
first = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
first_cookie = first.headers["set-cookie"].split(";")[0]
second = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
second_cookie = second.headers["set-cookie"].split(";")[0]
assert first_cookie != second_cookie # fresh token every login
# ----------------------------------------------------------- guard + CSRF
async def test_unauthenticated_401_on_protected_routes(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
for method, path in [
("GET", "/api/methodologies"),
("GET", "/api/config"),
("GET", "/api/runs/abcd2345"),
("GET", "/api/runs/abcd2345/events"),
("GET", "/api/runs/abcd2345/report"),
("GET", "/api/admin/users"),
]:
response = await client.request(method, path)
assert response.status_code == 401, f"{method} {path} leaked without auth"
assert response.json()["detail"] == "authentication required"
async def test_csrf_header_required_on_non_get(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
# Session cookie attached, but no CSRF header on a POST.
response = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
)
assert response.status_code == 403
assert response.json()["detail"] == "missing request header"
async def test_gets_exempt_from_csrf_header(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
assert (await client.get("/api/methodologies")).status_code == 200
assert (await client.get("/api/auth/me")).status_code == 200
# ----------------------------------------------------------- me + logout
async def test_me_returns_current_user(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.get("/api/auth/me")
assert response.status_code == 200
body = response.json()
assert body["email"] == _ADMIN_EMAIL
assert "password_hash" not in body
async def test_logout_revokes_session(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
assert (await client.get("/api/methodologies")).status_code == 200
response = await client.post("/api/auth/logout", headers=_HEADERS)
assert response.status_code == 200
assert (await client.get("/api/methodologies")).status_code == 401
async def test_deactivated_user_401(tmp_path: Path) -> None:
app = _app(tmp_path)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _register_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
assert created.status_code == 201
second_id = created.json()["user_id"]
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
await second.post(
"/api/auth/login",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
assert (await second.get("/api/methodologies")).status_code == 200
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
deactivated = await admin.post(
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
)
assert deactivated.status_code == 200
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
# The deactivated user's session was revoked server-side.
response = await second.get("/api/methodologies")
assert response.status_code == 401
# A fresh login against the deactivated account also 401s —
# uniformly, without revealing the reason (no enumeration).
login = await second.post(
"/api/auth/login",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
assert login.status_code == 401
assert login.json()["detail"] == "invalid email or password"
# ------------------------------------------------------------------- admin
async def test_admin_list_users_no_hashes(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.get("/api/admin/users")
assert response.status_code == 200
body = response.json()
assert len(body) == 1
raw = json.dumps(body)
assert "password_hash" not in raw
assert _ADMIN_PASSWORD not in raw
assert "argon2" not in raw
async def test_admin_create_user_then_user_login(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
assert created.status_code == 201
body = created.json()
assert body["role"] == "user"
assert body["active"] is True
login = await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
assert login.status_code == 200
assert login.json()["role"] == "user"
async def test_admin_create_rejects_short_password(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": "short"},
headers=_HEADERS,
)
assert response.status_code == 422
async def test_admin_deactivate_revokes_sessions(tmp_path: Path) -> None:
app = _app(tmp_path)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as admin:
await _register_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
second_id = created.json()["user_id"]
# A separate client (separate cookie jar) for the second user.
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as second:
await second.post(
"/api/auth/login",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
assert (await second.get("/api/methodologies")).status_code == 200
# Deactivation from the admin session kills the second session.
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as admin:
await _register_first_admin(admin) # store shared; already admin
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
deactivated = await admin.post(
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
)
assert deactivated.status_code == 200
assert deactivated.json()["active"] is False
response = await second.get("/api/methodologies")
assert response.status_code == 401 # session revoked by deactivation
async def test_non_admin_cannot_reach_admin_routes(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
response = await client.get("/api/admin/users")
assert response.status_code == 403
assert response.json()["detail"] == "admin role required"
async def test_admin_deactivate_unknown_user_404(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await client.post("/api/admin/users/zzzz9999/deactivate", headers=_HEADERS)
assert response.status_code == 404
# ------------------------------------------------------ authenticated runs
async def test_cross_owner_access_404_on_every_run_endpoint(tmp_path: Path) -> None:
"""Two-user isolation (REQ-71): zero visibility across every run
endpoint — cross-owner access is indistinguishable from a missing
run (404, not 403: no existence leak)."""
app = _app(tmp_path)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _register_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/runs",
json={"question": "admin's private question", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
run_id = created.json()["id"]
await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
login = await _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
assert login.status_code == 200
del login # ids don't matter here; counters do
for method, path in [
("GET", f"/api/runs/{run_id}"),
("POST", f"/api/runs/{run_id}/approve"),
("POST", f"/api/runs/{run_id}/pause"),
("POST", f"/api/runs/{run_id}/resume"),
("POST", f"/api/runs/{run_id}/cancel"),
("GET", f"/api/runs/{run_id}/events"),
("GET", f"/api/runs/{run_id}/report"),
("GET", f"/api/runs/{run_id}/ledger"),
]:
response = await second.request(method, path, headers=_HEADERS)
assert response.status_code == 404, f"{method} {path} leaked across owners"
# The owner still sees their run — the 404s were authorization, not
# the run vanishing.
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
response = await admin.get(f"/api/runs/{run_id}")
assert response.status_code == 200
async def test_owner_id_recorded_in_persisted_state(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
login = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
user_id = login.json()["user_id"]
created = await client.post(
"/api/runs",
json={"question": "q", "methodology": "rapid-answer"},
headers=_HEADERS,
)
run_id = created.json()["id"]
state_path = tmp_path / "runs" / user_id / run_id / "state.json"
for _ in range(200): # the actor persists asynchronously
if state_path.exists():
break
await asyncio.sleep(0.01)
assert state_path.exists(), "storage layout must be runs/<user_id>/<run_id>/"
state_text = state_path.read_text()
state = json.loads(state_text)
assert state["owner_id"] == user_id
async def test_cost_counters_owner_keyed(tmp_path: Path) -> None:
app = _app(tmp_path)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await _register_first_admin(admin)
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await admin.post(
"/api/runs",
json={"question": "q", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
config = await admin.get("/api/config")
counters = config.json()["cost_counters"]
assert counters["runs_created"] == 1 # the admin's own view, owner-keyed
await admin.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
headers=_HEADERS,
)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
await _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
created = await second.post(
"/api/runs",
json={"question": "q2", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
config = await second.get("/api/config")
assert config.json()["cost_counters"] == {"runs_created": 1, "runs_completed": 0}
async def test_authenticated_run_lifecycle(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
created = await client.post(
"/api/runs",
json={"question": "How do engines run?", "methodology": "rapid-answer"},
headers=_HEADERS,
)
assert created.status_code == 201
run_id = created.json()["id"]
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "awaiting_approval":
break
await asyncio.sleep(0.01)
approved = await client.post(f"/api/runs/{run_id}/approve", headers=_HEADERS)
assert approved.status_code == 200
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "done":
break
await asyncio.sleep(0.01)
report = await client.get(f"/api/runs/{run_id}/report")
assert report.status_code == 200
assert "[p-1]" in report.json()["markdown"]
async def test_no_password_hash_in_any_response(tmp_path: Path) -> None:
"""Every response body of a full auth + run session, scanned."""
async with await _client(tmp_path) as client:
await _register_first_admin(client)
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
await client.post(
"/api/admin/users",
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
headers=_HEADERS,
)
responses = [
await client.get("/api/auth/me"),
await client.get("/api/admin/users"),
await client.get("/api/methodologies"),
await client.get("/api/config"),
await client.get("/api/runs/abcd2345"),
]
for response in responses:
raw = response.text
assert "$argon2" not in raw # no hash material, ever
assert '"password_hash"' not in raw # no hash field, ever
assert _ADMIN_PASSWORD not in raw
assert _SECOND_PASSWORD not in raw
+8 -8
View File
@@ -46,7 +46,7 @@ def _actor(tmp_path: Path) -> tuple[RunActor, EventBus, FakeSearchAdapter]:
)
model = FakeModelAdapter(canned=_canned())
search = FakeSearchAdapter(CORPUS)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
bus = EventBus()
actor = RunActor(
run_config=config,
@@ -71,20 +71,20 @@ async def test_full_walk_reaches_done(tmp_path: Path) -> None:
assert state.state == RunStateName.DONE
assert state.plan is not None and len(state.plan.sub_questions) == 2
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "DONE"
report = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "report.md")
report = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "report.md")
assert "[p-1]" in report
ledger_raw = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
ledger_raw = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact(
"abcd2345", "ledger.json"
)
ledger = json.loads(ledger_raw)
assert ledger["checklist"]["citations_checked"] >= 1
assert ledger["checklist"]["liveness_ratio"] >= 0
checklist = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
checklist = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact(
"abcd2345", "checklist.md"
)
assert "Verification Checklist" in checklist
@@ -121,7 +121,7 @@ async def test_cancel_persists_cancelled_state(tmp_path: Path) -> None:
await actor.approve()
with pytest.raises(asyncio.CancelledError):
await task
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "CANCELLED"
@@ -160,14 +160,14 @@ async def test_planner_non_json_fails_honestly(tmp_path: Path) -> None:
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
task = asyncio.create_task(actor.run())
with pytest.raises(Exception, match="planner returned non-JSON plan"):
await task
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "FAILED"
+5 -2
View File
@@ -31,7 +31,7 @@ async def test_registry_to_fake_model_to_snapshot_round_trip(tmp_path) -> None:
)
assert run_config.resolved_dials.breadth == 3
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
await storage.write_run_artifact(run_id, "run_config.json", run_config.model_dump_json())
restored = RunConfig.model_validate_json(
@@ -70,9 +70,12 @@ def test_override_rejects_unknown_dial() -> None:
def test_new_run_id_format() -> None:
# P-14: one generator, one alphabet (config/ids.py) — the old
# token_hex(4) duplicate is gone.
run_id = new_run_id()
assert len(run_id) == 8
assert all(c in "0123456789abcdef" for c in run_id)
alphabet = set("abcdefghjkmnpqrstuvwxyz23456789")
assert all(c in alphabet for c in run_id)
async def test_fake_search_feeds_evidence_shape() -> None:
+70 -10
View File
@@ -4,9 +4,15 @@ from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.storage import StorageAdapter
from researchx.adapters.types import AdapterError
_OWNER = "u1a2b3c4"
def _storage(tmp_path: Path) -> LocalStorageAdapter:
return LocalStorageAdapter(base_dir=tmp_path, owner_id=_OWNER)
async def test_storage_round_trip(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
assert isinstance(storage, StorageAdapter)
target = await storage.write_run_artifact("abcd2345", "state.json", '{"state": "PLANNING"}')
assert target.exists()
@@ -15,24 +21,52 @@ async def test_storage_round_trip(tmp_path: Path) -> None:
async def test_atomic_overwrite(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 2}')
assert await storage.read_run_artifact("abcd2345", "state.json") == '{"v": 2}'
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
leftovers = list((tmp_path / "runs" / _OWNER / "abcd2345").glob("*.tmp"))
assert leftovers == []
async def test_run_dir_layout(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
run_dir = storage.run_dir("abcd2345")
assert run_dir == tmp_path / "runs" / "abcd2345"
# D-106: runs/<owner_id>/<run_id>/ partitioning (supersedes D-010).
assert run_dir == tmp_path / "runs" / _OWNER / "abcd2345"
await storage.write_run_artifact("abcd2345", "report.md", "# Report")
assert run_dir.is_dir()
async def test_owner_id_required_and_validated(tmp_path: Path) -> None:
for bad_owner in ["", "short", "waytoolongid123", "ABCD2345", "a b c d e", "../evil"]:
try:
LocalStorageAdapter(base_dir=tmp_path, owner_id=bad_owner)
except AdapterError:
pass
else:
raise AssertionError(f"expected AdapterError for owner_id: {bad_owner!r}")
# owner_id is required — no default partition key.
try:
LocalStorageAdapter(base_dir=tmp_path) # type: ignore[call-arg]
except TypeError:
pass
else:
raise AssertionError("owner_id must be a required positional parameter")
async def test_read_missing_error_embeds_owner_path(tmp_path: Path) -> None:
storage = _storage(tmp_path)
try:
await storage.read_run_artifact("zzzz9999", "state.json")
except AdapterError as exc:
assert f"runs/{_OWNER}/zzzz9999/state.json" in str(exc)
else:
raise AssertionError("expected AdapterError")
async def test_read_missing_raises(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
try:
await storage.read_run_artifact("zzzz9999", "state.json")
except AdapterError:
@@ -42,7 +76,7 @@ async def test_read_missing_raises(tmp_path: Path) -> None:
async def test_path_traversal_rejected(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
for bad_name in ["../escape.md", "/etc/passwd", "a/b.md", "", "."]:
try:
await storage.write_run_artifact("abcd2345", bad_name, "x")
@@ -60,7 +94,33 @@ async def test_path_traversal_rejected(tmp_path: Path) -> None:
async def test_no_tmp_leftover_after_write(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = _storage(tmp_path)
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
assert leftovers == []
leftovers = list((tmp_path / "runs" / _OWNER / "abcd2345").glob("*.tmp"))
assert leftovers == []
async def test_partition_isolation_between_owners(tmp_path: Path) -> None:
alice = LocalStorageAdapter(base_dir=tmp_path, owner_id="aaaa1111")
bob = LocalStorageAdapter(base_dir=tmp_path, owner_id="bbbb2222")
await alice.write_run_artifact("abcd2345", "state.json", '{"owner": "alice"}')
try:
await bob.read_run_artifact("abcd2345", "state.json")
except AdapterError:
pass
else:
raise AssertionError("owner partitions must be isolated on disk")
async def test_list_run_ids_scans_owner_partition(tmp_path: Path) -> None:
storage = _storage(tmp_path)
assert storage.list_run_ids() == []
await storage.write_run_artifact("abcd2345", "state.json", "{}")
await storage.write_run_artifact("efgh6789", "state.json", "{}")
# A file (not a dir) and dotfiles are skipped defensively.
(tmp_path / "runs" / _OWNER / "notadir.md").write_text("x")
(tmp_path / "runs" / _OWNER / ".hidden").mkdir()
assert storage.list_run_ids() == ["abcd2345", "efgh6789"]
other = LocalStorageAdapter(base_dir=tmp_path, owner_id="zzzz9999")
assert other.list_run_ids() == [] # separate partition, empty
+4 -4
View File
@@ -59,7 +59,7 @@ async def test_evidence_reaches_writer_prompt(tmp_path: Path) -> None:
run_config=config,
model=model, # type: ignore[arg-type]
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
@@ -101,7 +101,7 @@ async def test_resume_from_searching_mid_run(tmp_path: Path) -> None:
question="q", context="", methodology_slug="rapid-answer",
overrides={}, registry=registry, run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
mid_state = RunState(
run_id="abcd2345",
question="q",
@@ -148,7 +148,7 @@ async def test_cancel_after_approval_persists_cancelled(tmp_path: Path) -> None:
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
@@ -161,7 +161,7 @@ async def test_cancel_after_approval_persists_cancelled(tmp_path: Path) -> None:
with pytest.raises(asyncio.CancelledError):
await task
saved = json.loads(
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
)
assert saved["state"] == "CANCELLED"
+3 -3
View File
@@ -57,7 +57,7 @@ async def test_probe_attaches_to_run_state(tmp_path: Path, monkeypatch) -> None:
run_config=config,
model=fake_model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=EventBus(),
)
@@ -72,7 +72,7 @@ async def test_probe_attaches_to_run_state(tmp_path: Path, monkeypatch) -> None:
state = await task
assert state.state == RunStateName.DONE
saved = json.loads(
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
)
assert saved["probe"]["endpoint"] == "https://ollama.com/v1"
@@ -116,7 +116,7 @@ async def test_probe_failure_never_crashes_the_run(tmp_path: Path, monkeypatch)
run_config=config,
model=fake_model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
registry=registry,
event_bus=bus,
)
+2 -2
View File
@@ -40,7 +40,7 @@ async def test_state_file_round_trip_enables_restart(tmp_path: Path) -> None:
registry=registry,
run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
state = RunState(
run_id=run_id,
question=config.question,
@@ -68,7 +68,7 @@ async def test_actor_resumes_from_restored_state(tmp_path: Path) -> None:
registry=registry,
run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
mid_state = RunState(
run_id=run_id,
question=config.question,
+247
View File
@@ -0,0 +1,247 @@
"""Restart sweep + registry pruning tests (T-1.8, D-115/G-101, P-6)."""
import asyncio
import json
from pathlib import Path
import httpx
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.types import ChatResult
from researchx.api.app import RunRegistry, create_app
from researchx.api.sweep import sweep_in_flight_runs
from researchx.auth.guard import CSRF_HEADER
from researchx.engine.run_state import RunState
from researchx.engine.states import RunStateName, can_transition
_PLAN = json.dumps(
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
)
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
_OWNER = "abcd2345" # owner slug used for fixture trees
def _state_file(run_dir: Path) -> Path:
run_dir.mkdir(parents=True, exist_ok=True)
return run_dir / "state.json"
def _write_state(run_dir: Path, state: RunStateName) -> Path:
state_path = _state_file(run_dir)
record = RunState(
run_id=run_dir.name,
question="q",
methodology_slug="rapid-answer",
state=state,
owner_id=_OWNER,
)
state_path.write_text(record.model_dump_json(), encoding="utf-8")
return state_path
# ------------------------------------------------------------------- G-101
def test_planning_to_cancelled_is_now_legal() -> None:
"""G-101: the sweep's PLANNING -> CANCELLED rewrite is a legal transition."""
assert can_transition(RunStateName.PLANNING, RunStateName.CANCELLED)
def test_sweep_rewrites_planning_fixture_to_cancelled(tmp_path: Path) -> None:
base = tmp_path
_write_state(base / "runs" / _OWNER / "abcd2345", RunStateName.PLANNING)
swept = sweep_in_flight_runs(base)
assert swept == [(_OWNER, "abcd2345")]
state = RunState.model_validate_json(
(base / "runs" / _OWNER / "abcd2345" / "state.json").read_text(encoding="utf-8")
)
assert state.state == RunStateName.CANCELLED
def test_sweep_rewrites_every_non_terminal_state(tmp_path: Path) -> None:
base = tmp_path
non_terminal = [
RunStateName.PLANNING,
RunStateName.AWAITING_APPROVAL,
RunStateName.SEARCHING,
RunStateName.EXTRACTING,
RunStateName.REFLECTING,
RunStateName.SYNTHESIZING,
RunStateName.VERIFYING,
]
for state in non_terminal:
_write_state(base / "runs" / _OWNER / f"run{state.value[:4].lower()}", state)
swept = sweep_in_flight_runs(base)
assert len(swept) == len(non_terminal)
for _, run_id in swept:
record = RunState.model_validate_json(
(base / "runs" / _OWNER / run_id / "state.json").read_text(encoding="utf-8")
)
assert record.state == RunStateName.CANCELLED
def test_sweep_leaves_terminal_states_untouched(tmp_path: Path) -> None:
base = tmp_path
_write_state(base / "runs" / _OWNER / "done0001", RunStateName.DONE)
_write_state(base / "runs" / _OWNER / "fail0001", RunStateName.FAILED)
_write_state(base / "runs" / _OWNER / "canc0001", RunStateName.CANCELLED)
assert sweep_in_flight_runs(base) == []
for run_id in ["done0001", "fail0001", "canc0001"]:
record = RunState.model_validate_json(
(base / "runs" / _OWNER / run_id / "state.json").read_text(encoding="utf-8")
)
assert record.state in {RunStateName.DONE, RunStateName.FAILED, RunStateName.CANCELLED}
def test_sweep_skips_corrupt_state_and_dotfiles(tmp_path: Path) -> None:
base = tmp_path
good = _write_state(base / "runs" / _OWNER / "abcd2345", RunStateName.SEARCHING)
corrupt = _state_file(base / "runs" / _OWNER / "zzzz9999")
corrupt.write_text("{not json", encoding="utf-8")
(base / "runs" / _OWNER / ".hidden").mkdir() # dotfile dir skipped
(base / "runs" / ".hidden-owner").mkdir() # dotfile owner skipped
swept = sweep_in_flight_runs(base)
assert swept == [(_OWNER, "abcd2345")]
assert corrupt.read_text(encoding="utf-8") == "{not json" # untouched
del good
def test_sweep_on_empty_base_dir_is_noop(tmp_path: Path) -> None:
assert sweep_in_flight_runs(tmp_path) == []
assert sweep_in_flight_runs(tmp_path / "missing") == []
def test_create_app_sweeps_on_construction(tmp_path: Path) -> None:
"""The PLANNING fixture -> create_app -> state is CANCELLED on disk."""
_write_state(tmp_path / "runs" / _OWNER / "abcd2345", RunStateName.PLANNING)
create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[ChatResult(content=_PLAN, model="fake-planner")]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
state = RunState.model_validate_json(
(tmp_path / "runs" / _OWNER / "abcd2345" / "state.json").read_text(encoding="utf-8")
)
assert state.state == RunStateName.CANCELLED
# --------------------------------------------------------------------- P-6
async def test_registry_prune_drops_terminal_run_with_zero_subscribers() -> None:
registry = RunRegistry()
assert registry.prune("abcd2345", "efgh6789") is False # unknown entry: no-op
# Build a fake terminal entry.
from researchx.engine.events import EventBus
bus = EventBus()
class _FakeActor:
pass
class _FakeState:
state = RunStateName.DONE
fake_actor = _FakeActor()
fake_actor.state = _FakeState() # type: ignore[assignment]
async def _noop() -> RunState:
raise NotImplementedError
task: object = asyncio.Future() # placeholder, never awaited
registry.add("abcd2345", "efgh6789", fake_actor, task, bus) # type: ignore[arg-type]
assert registry.has("abcd2345", "efgh6789")
# A live subscriber blocks pruning.
registry.subscribe_events("abcd2345", "efgh6789")
assert registry.prune("abcd2345", "efgh6789") is False
assert registry.has("abcd2345", "efgh6789")
# Last subscriber leaves -> terminal run pruned.
registry.unsubscribe_events("abcd2345", "efgh6789")
assert registry.prune("abcd2345", "efgh6789") is True
assert not registry.has("abcd2345", "efgh6789")
assert registry.prune("abcd2345", "efgh6789") is False # already gone
def test_registry_refcounts_multiple_subscribers() -> None:
registry = RunRegistry()
registry.subscribe_events("abcd2345", "efgh6789")
registry.subscribe_events("abcd2345", "efgh6789")
assert registry.subscriber_count("abcd2345", "efgh6789") == 2
registry.unsubscribe_events("abcd2345", "efgh6789")
assert registry.subscriber_count("abcd2345", "efgh6789") == 1
registry.unsubscribe_events("abcd2345", "efgh6789")
assert registry.subscriber_count("abcd2345", "efgh6789") == 0
registry.unsubscribe_events("abcd2345", "efgh6789") # underflow-safe
assert registry.subscriber_count("abcd2345", "efgh6789") == 0
async def test_events_for_pruned_run_404(tmp_path: Path) -> None:
"""Terminal run + all subscribers gone -> registry drops it; the
events endpoint 404s afterwards (documented acceptable — the
trace persists on disk, D-115/P-6)."""
app = create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER, model="fake-writer"),
]
),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
)
run_id = created.json()["id"]
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "awaiting_approval":
break
await asyncio.sleep(0.01)
await client.post(f"/api/runs/{run_id}/approve", headers={CSRF_HEADER: "1"})
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "done":
break
await asyncio.sleep(0.01)
# Consume the full SSE replay; on stream end the run prunes.
collected = b""
async with client.stream("GET", f"/api/runs/{run_id}/events") as response:
assert response.status_code == 200
async for chunk in response.aiter_bytes():
collected += chunk
if b"run_done" in collected:
break
await asyncio.sleep(0.1) # let the finally-block prune
# The report still exists on disk...
report = await client.get(f"/api/runs/{run_id}/report")
assert report.status_code in (200, 404) # read-model parity lands in P3
state_path = list((tmp_path / "runs").glob(f"*/{run_id}/state.json"))
assert state_path, "trace/state persists on disk after prune"
# ...and a second events request for the pruned run 404s (the
# documented post-prune behavior).
assert (await client.get(f"/api/runs/{run_id}/events")).status_code == 404
+263
View File
@@ -0,0 +1,263 @@
"""Store-level tests for the auth package (T-1.1/T-1.2/T-1.3, REQ-69)."""
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from researchx.auth.sessions import SESSION_TTL_SECONDS, Session, SessionStore
from researchx.auth.users import MIN_PASSWORD_LENGTH, UserError, UserStore
_EMAIL = "owner@example.org"
_PASSWORD = "correct-horse-battery"
def _store(tmp_path: Path) -> UserStore:
return UserStore(tmp_path / "users.json")
def _sessions(tmp_path: Path) -> SessionStore:
return SessionStore(tmp_path / "sessions.json")
# ------------------------------------------------------------------ users
async def test_user_store_create_get_list(tmp_path: Path) -> None:
store = _store(tmp_path)
assert store.empty() is True
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner", role="admin"
)
assert store.empty() is False
assert user.role == "admin"
assert user.active is True
assert len(user.user_id) == 8
assert user.user_id.isascii() and user.user_id.islower()
fetched = store.get(user.user_id)
assert fetched is not None and fetched.email == _EMAIL
by_email = store.get_by_email("Owner@Example.ORG")
assert by_email is not None and by_email.user_id == user.user_id
listed = store.list()
assert [u.user_id for u in listed] == [user.user_id]
assert store.get("zzzz9999") is None
async def test_password_policy_min_12_enforced(tmp_path: Path) -> None:
store = _store(tmp_path)
with pytest.raises(UserError, match="at least 12"):
await store.create_user(
email=_EMAIL, password="short", display_name="Owner"
)
assert len("short") < MIN_PASSWORD_LENGTH
assert store.empty() is True # nothing inserted
with pytest.raises(UserError):
await store.create_user(
email=_EMAIL, password="a" * (MIN_PASSWORD_LENGTH - 1), display_name="Owner"
)
assert store.empty() is True
ok = await store.create_user(
email=_EMAIL, password="a" * MIN_PASSWORD_LENGTH, display_name="Owner"
)
assert ok.user_id
async def test_duplicate_email_rejected(tmp_path: Path) -> None:
store = _store(tmp_path)
await store.create_user(email=_EMAIL, password=_PASSWORD, display_name="A")
with pytest.raises(UserError, match="already registered"):
await store.create_user(
email="OWNER@example.org", password=_PASSWORD, display_name="B"
)
assert len(store.list()) == 1
async def test_argon2_hash_verify_round_trip(tmp_path: Path) -> None:
store = _store(tmp_path)
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner"
)
assert user.password_hash != _PASSWORD
assert user.password_hash.startswith("$argon2")
assert await store.verify_password(user, _PASSWORD) is True
wrong = user.model_copy(update={"password_hash": user.password_hash})
assert await store.verify_password(wrong, "wrong-password-entirely") is False
other = await _store(tmp_path).create_user(
email="b@example.org", password="another-password-12", display_name="B"
)
assert other.password_hash != user.password_hash # unique salts
async def test_user_store_persists_across_reload(tmp_path: Path) -> None:
path = tmp_path / "users.json"
store = UserStore(path)
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner", role="admin"
)
assert path.exists()
reloaded = UserStore(path)
fetched = reloaded.get(user.user_id)
assert fetched is not None
assert fetched.email == _EMAIL
assert await reloaded.verify_password(fetched, _PASSWORD) # hash survives the round-trip
assert reloaded.get_by_email("OWNER@EXAMPLE.ORG") is not None
async def test_user_store_deactivate(tmp_path: Path) -> None:
store = _store(tmp_path)
user = await store.create_user(
email=_EMAIL, password=_PASSWORD, display_name="Owner"
)
updated = store.deactivate(user.user_id)
assert updated is not None and updated.active is False
assert store.get(user.user_id).active is False
assert store.deactivate("zzzz9999") is None
# ---------------------------------------------------------------- sessions
async def test_session_create_get_round_trip(tmp_path: Path) -> None:
store = _store(tmp_path)
user = await store.create_user(email=_EMAIL, password=_PASSWORD, display_name="Owner")
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, user.user_id)
assert session.token_hash != token # hash-only storage
assert len(session.token_hash) == 64 # sha256 hex
resolved = sessions.get(token)
assert resolved is not None
assert resolved.user_id == user.user_id
assert sessions.get("not-a-real-token") is None
async def test_session_expiry(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, "abcd2345")
assert session.expires_at > datetime.now(UTC)
# Expired sessions resolve to None and are evicted.
expired = Session(
token_hash=session.token_hash,
user_id="abcd2345",
created_at=datetime.now(UTC) - timedelta(days=15),
last_seen=datetime.now(UTC) - timedelta(days=15),
expires_at=datetime.now(UTC) - timedelta(seconds=1),
)
sessions._sessions[session.token_hash] = expired
assert sessions.get(token) is None
assert session.token_hash not in sessions._sessions
async def test_session_sliding_touch(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, "abcd2345")
original_expiry = session.expires_at
old_seen = datetime.now(UTC) - timedelta(days=10)
stale = session.model_copy(
update={"last_seen": old_seen, "expires_at": old_seen + timedelta(seconds=1)}
)
sessions._sessions[stale.token_hash] = stale
sessions.touch(stale)
renewed = sessions.get(token)
assert renewed is not None
assert renewed.expires_at > original_expiry - timedelta(days=10)
assert renewed.last_seen > old_seen
assert renewed.expires_at - renewed.last_seen == timedelta(seconds=SESSION_TTL_SECONDS)
def test_session_touch_persist_throttle(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token = sessions.generate_token()
session = sessions.create(token, "abcd2345")
path = sessions._path
first_write = path.stat().st_mtime_ns
sessions.touch(session) # within 60s of the create persist → throttled
assert path.stat().st_mtime_ns == first_write
sessions._last_persist -= 61.0 # simulate the throttle window elapsing
session = sessions.get(token)
assert session is not None
sessions.touch(session)
assert path.stat().st_mtime_ns != first_write
async def test_session_revoke_semantics(tmp_path: Path) -> None:
sessions = _sessions(tmp_path)
token_a = sessions.generate_token()
token_b = sessions.generate_token()
token_c = sessions.generate_token()
sessions.create(token_a, "abcd2345")
sessions.create(token_b, "abcd2345")
sessions.create(token_c, "efgh6789")
sessions.revoke(token_a)
assert sessions.get(token_a) is None
assert sessions.get(token_b) is not None # siblings survive single revoke
sessions.revoke_all_for("abcd2345")
assert sessions.get(token_b) is None
assert sessions.get(token_c) is not None # other users untouched
sessions.revoke("unknown-token") # idempotent
sessions.revoke_all_for("zzzz9999")
async def test_session_store_persists_across_reload(tmp_path: Path) -> None:
path = tmp_path / "sessions.json"
sessions = SessionStore(path)
token = sessions.generate_token()
created = sessions.create(token, "abcd2345")
del created
reloaded = SessionStore(path)
assert reloaded.get(token) is not None
reloaded.revoke(token)
assert SessionStore(path).get(token) is None # revocation persisted too
# ------------------------------------------------------- corruption posture
def test_corrupt_user_store_fails_closed(tmp_path: Path) -> None:
"""A corrupt users.json must NOT silently become an empty store:
swallowing it would reopen registration on top of existing
accounts and the next persist would overwrite them. The store
must refuse to start (file moved aside for operator recovery)."""
path = tmp_path / "users.json"
path.write_text("{not json", encoding="utf-8")
with pytest.raises(RuntimeError, match="corrupt user store"):
UserStore(path)
salvage = list(tmp_path.glob("users.json.corrupt.*"))
assert salvage, "the corrupt file must be moved aside, not deleted"
assert not path.exists()
def test_corrupt_session_store_fails_toward_no_access(tmp_path: Path) -> None:
"""A corrupt sessions.json degrades to 'everyone logged out' —
the fail-safe direction for sessions (no access, not all access).
Startup must still succeed; sessions are re-created on next login."""
path = tmp_path / "sessions.json"
path.write_text("{not json", encoding="utf-8")
sessions = SessionStore(path) # must not raise
token = sessions.generate_token()
sessions.create(token, "abcd2345")
assert SessionStore(path).get(token) is not None # recovers on rewrite
+153 -1
View File
@@ -3,7 +3,8 @@ revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
"python_full_version == '3.14.*'",
"python_full_version < '3.14'",
]
[[package]]
@@ -28,6 +29,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" },
{ url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" },
{ url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" },
{ url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" },
{ url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" },
{ url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" },
{ url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" },
{ url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" },
{ url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" },
{ url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" },
{ url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" },
{ url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" },
{ url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" },
{ url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" },
{ url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" },
{ url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" },
{ url = "https://files.pythonhosted.org/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb", size = 25683, upload-time = "2026-08-20T07:33:05.337Z" },
{ url = "https://files.pythonhosted.org/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6", size = 27310, upload-time = "2026-08-20T07:33:06.344Z" },
{ url = "https://files.pythonhosted.org/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990", size = 26771, upload-time = "2026-08-20T07:33:07.429Z" },
{ url = "https://files.pythonhosted.org/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08", size = 27569, upload-time = "2026-08-20T07:33:08.598Z" },
{ url = "https://files.pythonhosted.org/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca", size = 27279, upload-time = "2026-08-20T07:33:09.518Z" },
{ url = "https://files.pythonhosted.org/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1", size = 27774, upload-time = "2026-08-20T07:33:10.728Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36", size = 26933, upload-time = "2026-08-20T07:33:11.661Z" },
{ url = "https://files.pythonhosted.org/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210", size = 24875, upload-time = "2026-08-20T07:33:12.616Z" },
{ url = "https://files.pythonhosted.org/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4", size = 26655, upload-time = "2026-08-20T07:33:13.521Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440", size = 25376, upload-time = "2026-08-20T07:33:14.491Z" },
]
[[package]]
name = "ast-serialize"
version = "0.11.2"
@@ -101,6 +157,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@@ -379,6 +520,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
@@ -512,6 +662,7 @@ name = "researchx"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "argon2-cffi" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
@@ -530,6 +681,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "argon2-cffi", specifier = ">=23.1,<26" },
{ name = "fastapi", specifier = ">=0.115,<0.116" },
{ name = "httpx", specifier = ">=0.27,<1" },
{ name = "pydantic", specifier = ">=2.8,<3" },
+30 -7
View File
@@ -1,12 +1,5 @@
# ResearchX Frontend
**TL;DR** — This is the ResearchX web interface: ask a question, choose a research method, approve the plan, watch the live trace, read the cited report. The prototype runs entirely on scripted sample data — no API keys, no backend, no network — so the full product journey is tryable today.
- **What** — A Next.js app (ask, choose, run, report, library, settings) rendering mock data through one typed client interface.
- **Why** — The journey is checkable end-to-end before live inference exists.
- **Action** — no action — FYI. Developers: Quickstart below (Node 20+, pnpm).
- **Details** — Root `README.md`: backend engine, live mode, architecture.
The prototype frontend for ResearchX — methodology-driven deep research
with a trust loop. This milestone (v0.1) runs **entirely on mock data**:
no API keys, no env vars, no network calls beyond static assets.
@@ -73,6 +66,36 @@ hosts is wayfinding and accumulated research:
| `pnpm typecheck` | `tsc --noEmit` (TypeScript strict) |
| `pnpm test` | Vitest run (jsdom + Testing Library) |
| `pnpm test:watch` | Vitest in watch mode |
| `pnpm generate:api` | Regenerate `src/lib/api/generated/schema.d.ts` from the committed `openapi.json` snapshot (G-005 — never from a live server) |
| `pnpm check:api-drift` | Schema-drift gate: regenerates from the snapshot and diffs (exit 1 on drift — G-005) |
## Auth + sessions (v0.3 P1)
The ApiClient boundary gained the session surface (C3-18 — the
exhaustive list): `login` / `logout` / `getSession` plus the admin
hooks `listUsers` / `createUser` / `deactivateUser`.
- **Mock mode** (the default): a standalone dev fixture — login accepts
any non-empty credentials and swaps an in-memory session (the dev
admin `dev00000`), admin hooks return small fixture arrays, and the
app is never gated behind a login (REQ-31/REQ-54).
- **Live mode** (`NEXT_PUBLIC_RESEARCHX_API_MODE=live`): real routes —
`POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`
(401 → null), `GET /api/admin/users`, `POST /api/admin/users`,
`POST /api/admin/users/{user_id}/deactivate`. Every mutating request
carries the `X-ResearchX-Request` CSRF header (C3-05) and every
request sends `credentials: "include"` so the httpOnly session
cookie rides along. A failed login rejects with a typed `AuthError`
carrying the backend's uniform 401 copy.
The live-mode route guard (`SessionGate` in the root layout) bootstraps
the session on load: an unauthenticated visitor is redirected to
`/login` before any protected content renders; the login screen itself
is never gated. Mock mode skips the check entirely — same components,
zero mock-mode behavior change. Dev-live origins must share the
`localhost` hostname on both sides (`http://localhost:8000` backend +
`http://localhost:3000` frontend — the port is not part of the
same-site computation).
## Architecture notes
+380 -1
View File
@@ -5,6 +5,262 @@
"version": "0.2.0"
},
"paths": {
"/healthz": {
"get": {
"summary": "Healthz",
"operationId": "healthz_healthz_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"title": "Response Healthz Healthz Get"
}
}
}
}
}
}
},
"/api/auth/register": {
"post": {
"tags": [
"auth"
],
"summary": "Register",
"operationId": "register_api_auth_register_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterBody"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPublic"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/auth/login": {
"post": {
"tags": [
"auth"
],
"summary": "Login",
"operationId": "login_api_auth_login_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPublic"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/auth/logout": {
"post": {
"tags": [
"auth"
],
"summary": "Logout",
"operationId": "logout_api_auth_logout_post",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "boolean"
},
"type": "object",
"title": "Response Logout Api Auth Logout Post"
}
}
}
}
}
}
},
"/api/auth/me": {
"get": {
"tags": [
"auth"
],
"summary": "Me",
"operationId": "me_api_auth_me_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPublic"
}
}
}
}
}
}
},
"/api/admin/users": {
"get": {
"tags": [
"admin"
],
"summary": "List Users",
"operationId": "list_users_api_admin_users_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/UserPublic"
},
"type": "array",
"title": "Response List Users Api Admin Users Get"
}
}
}
}
}
},
"post": {
"tags": [
"admin"
],
"summary": "Create User",
"operationId": "create_user_api_admin_users_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminCreateUserBody"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPublic"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/admin/users/{user_id}/deactivate": {
"post": {
"tags": [
"admin"
],
"summary": "Deactivate User",
"operationId": "deactivate_user_api_admin_users__user_id__deactivate_post",
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "User Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPublic"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/config": {
"get": {
"summary": "Config Status",
@@ -417,6 +673,39 @@
},
"components": {
"schemas": {
"AdminCreateUserBody": {
"properties": {
"email": {
"type": "string",
"maxLength": 254,
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
"title": "Email"
},
"password": {
"type": "string",
"minLength": 12,
"title": "Password"
},
"display_name": {
"type": "string",
"maxLength": 120,
"title": "Display Name",
"default": ""
},
"role": {
"type": "string",
"pattern": "^(admin|user)$",
"title": "Role",
"default": "user"
}
},
"type": "object",
"required": [
"email",
"password"
],
"title": "AdminCreateUserBody"
},
"ApproveBody": {
"properties": {
"plan": {
@@ -482,11 +771,13 @@
"properties": {
"question": {
"type": "string",
"maxLength": 2000,
"minLength": 1,
"title": "Question"
},
"context": {
"type": "string",
"maxLength": 8000,
"title": "Context",
"default": ""
},
@@ -520,6 +811,27 @@
"type": "object",
"title": "HTTPValidationError"
},
"LoginBody": {
"properties": {
"email": {
"type": "string",
"maxLength": 254,
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
"title": "Email"
},
"password": {
"type": "string",
"minLength": 1,
"title": "Password"
}
},
"type": "object",
"required": [
"email",
"password"
],
"title": "LoginBody"
},
"MethodologyCard": {
"properties": {
"slug": {
@@ -569,6 +881,33 @@
],
"title": "MethodologyCard"
},
"RegisterBody": {
"properties": {
"email": {
"type": "string",
"maxLength": 254,
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
"title": "Email"
},
"password": {
"type": "string",
"minLength": 12,
"title": "Password"
},
"display_name": {
"type": "string",
"maxLength": 120,
"title": "Display Name",
"default": ""
}
},
"type": "object",
"required": [
"email",
"password"
],
"title": "RegisterBody"
},
"RunSummary": {
"properties": {
"id": {
@@ -614,6 +953,46 @@
],
"title": "RunSummary"
},
"UserPublic": {
"properties": {
"user_id": {
"type": "string",
"title": "User Id"
},
"email": {
"type": "string",
"title": "Email"
},
"display_name": {
"type": "string",
"title": "Display Name"
},
"role": {
"type": "string",
"title": "Role"
},
"active": {
"type": "boolean",
"title": "Active"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"user_id",
"email",
"display_name",
"role",
"active",
"created_at"
],
"title": "UserPublic",
"description": "A user as exposed on the wire \u2014 no password_hash, ever."
},
"ValidationError": {
"properties": {
"loc": {
@@ -649,4 +1028,4 @@
}
}
}
}
}
+3 -1
View File
@@ -10,7 +10,9 @@
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"generate:api": "openapi-typescript ./openapi.json -o src/lib/api/generated/schema.d.ts",
"check:api-drift": "openapi-typescript ./openapi.json -o src/lib/api/generated/schema.d.ts --check"
},
"dependencies": {
"next": "15.5.6",
+13 -6
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "./globals.css";
import { AppSidebar } from "@/components/app-sidebar";
import { AppTopBar } from "@/components/app-top-bar";
import { SessionGate } from "@/components/session-gate";
/**
* Root layout (T-5.2, REQ-63) — the persistent app shell.
@@ -10,6 +11,10 @@ import { AppTopBar } from "@/components/app-top-bar";
* slots) and top bar (⌘K palette + avatar). The shell adds zero
* required clicks to the happy path — it is pure wayfinding chrome.
* Page content lands in one <main> landmark per route (a11y).
*
* v0.3 P1 (T-1.10): the SessionGate wraps the shell in live mode —
* an unauthenticated visitor is redirected to /login before any
* protected content renders. Mock mode passes straight through.
*/
export const metadata: Metadata = {
@@ -26,13 +31,15 @@ export default function RootLayout({
return (
<html lang="en">
<body>
<div className="flex min-h-dvh">
<AppSidebar />
<div className="flex min-w-0 flex-1 flex-col">
<AppTopBar />
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
<SessionGate>
<div className="flex min-h-dvh">
<AppSidebar />
<div className="flex min-w-0 flex-1 flex-col">
<AppTopBar />
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
</div>
</div>
</div>
</SessionGate>
</body>
</html>
);
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { LoginView } from "@/components/login-view";
/**
* /login (T-1.10, REQ-69) — the session entry point in live mode.
* Live-mode users land here via the app-shell guard when no session
* exists; mock mode never routes here (no auth — REQ-31).
*/
export const metadata: Metadata = {
title: "Sign in — ResearchX",
};
export default function LoginPage() {
return <LoginView />;
}
@@ -180,4 +180,19 @@ describe("UserMenu (REQ-66)", () => {
expect(await screen.findByText("Researcher")).toBeInTheDocument();
expect(screen.getByText("Local workspace — single user")).toBeInTheDocument();
});
it("logout calls the boundary then routes to /login (T-1.10, REQ-69)", async () => {
const user = userEvent.setup();
const onDismiss = vi.fn();
render(
<UserMenu onNavigate={() => {}} onDismiss={onDismiss} />,
);
const logout = await screen.findByRole("menuitem", { name: /Log out/i });
expect(logout).toBeInTheDocument();
await user.click(logout);
await vi.waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
expect(onDismiss).toHaveBeenCalled();
});
});
});
+2 -2
View File
@@ -14,8 +14,8 @@ import { usePathname, useRouter } from "next/navigation";
import { forwardRef, useEffect, useRef, useState } from "react";
import {
api,
type DisplayProfile,
type ReportLibrary,
type UserProfile,
} from "@/lib/api/client";
import { CommandPalette, type PaletteEntry } from "@/components/command-palette";
import { UserMenu } from "@/components/user-menu";
@@ -121,7 +121,7 @@ const AvatarButton = forwardRef<HTMLButtonElement, {
menuOpen: boolean;
onToggle: () => void;
}>(function AvatarButton({ menuOpen, onToggle }, ref) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [profile, setProfile] = useState<DisplayProfile | null>(null);
useEffect(() => {
let cancelled = false;
api
+130
View File
@@ -0,0 +1,130 @@
/**
* LoginView tests (T-1.10, REQ-69).
*
* - Renders the email + password form with a disabled submit until both
* fields are filled
* - Submit routes through the boundary: success → /ask; failure → the
* uniform "invalid email or password" error state (401, no
* user enumeration)
*
* The house pattern mocks NO boundary — instead this suite injects a
* boundary-shaped stub through module interception because login is
* the one call whose FAILURE path must be driven deterministically
* (the mock binding never fails a login). The stub speaks only the
* ApiClient surface.
*/
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LoginView } from "@/components/login-view";
import { AuthError, type ApiClient } from "@/lib/api/client";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
usePathname: () => "/login",
}));
/** A boundary-shaped stub driving success/failure of login (REQ-69). */
const loginMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/client", async (importOriginal) => {
const actual = await importOriginal<
Promise<typeof import("@/lib/api/client")>
>();
return {
...actual,
api: {
...(actual.api as unknown as ApiClient),
login: (...args: [string, string]) => loginMock(...args),
} as unknown as ApiClient,
};
});
const SESSION_USER = {
user_id: "abcd2345",
email: "owner@example.org",
display_name: "Owner",
role: "admin",
active: true,
created_at: "2026-09-29T12:00:00Z",
};
describe("LoginView (REQ-69)", () => {
beforeEach(() => {
pushMock.mockClear();
loginMock.mockClear();
});
afterEach(cleanup);
it("renders email + password fields and a disabled submit until both are set", async () => {
const user = userEvent.setup();
render(<LoginView />);
const email = screen.getByLabelText("Email");
const password = screen.getByLabelText("Password");
const submit = screen.getByRole("button", { name: "Sign in" });
expect(submit).toBeDisabled();
await user.type(email, "owner@example.org");
expect(submit).toBeDisabled(); // password still empty
await user.type(password, "not-a-real-secret");
expect(submit).toBeEnabled();
});
it("submits through the boundary and redirects to /ask on success", async () => {
loginMock.mockResolvedValueOnce(SESSION_USER);
const user = userEvent.setup();
render(<LoginView />);
await user.type(screen.getByLabelText("Email"), "owner@example.org");
await user.type(
screen.getByLabelText("Password"),
"correct-horse-battery",
);
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(loginMock).toHaveBeenCalledWith(
"owner@example.org",
"correct-horse-battery",
);
await vi.waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/ask");
});
});
it("shows the uniform 'invalid email or password' error on a rejected login", async () => {
// The backend answers every failure mode uniformly (REQ-69).
loginMock.mockRejectedValueOnce(new AuthError("invalid email or password", 401));
const user = userEvent.setup();
render(<LoginView />);
await user.type(screen.getByLabelText("Email"), "owner@example.org");
await user.type(screen.getByLabelText("Password"), "wrong-password");
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"invalid email or password",
);
expect(pushMock).not.toHaveBeenCalled();
// The submit re-enables for a retry.
expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled();
});
it("surfaces an honest unavailable state on a non-auth failure", async () => {
loginMock.mockRejectedValueOnce(new Error("network down"));
const user = userEvent.setup();
render(<LoginView />);
await user.type(screen.getByLabelText("Email"), "owner@example.org");
await user.type(screen.getByLabelText("Password"), "whatever");
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"sign-in unavailable",
);
expect(pushMock).not.toHaveBeenCalled();
});
});
+109
View File
@@ -0,0 +1,109 @@
"use client";
/**
* LoginView (T-1.10, REQ-69) — the /login screen client.
*
* Email + password against POST /api/auth/login through the ApiClient
* boundary. On success the app lands on /ask (the front door); on 401
* the error is the backend's uniform "invalid email or password" — no
* user enumeration, client-side included. A first-run hint points at
* the backend's register-once rule (the first user is admin).
*/
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { api, AuthError } from "@/lib/api/client";
export function LoginView() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = email.trim() !== "" && password !== "" && !submitting;
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
api
.login(email.trim(), password)
.then(() => {
router.push("/ask");
})
.catch((err: unknown) => {
// Uniform 401 copy — the backend never says WHICH half failed.
if (err instanceof AuthError) {
setError("invalid email or password");
} else {
setError("sign-in unavailable — try again");
}
setSubmitting(false);
});
}
return (
<div className="mx-auto flex max-w-md flex-col px-6 py-section">
<header className="mb-block">
<h1 className="text-display font-semibold text-ink">Sign in.</h1>
<p className="measure-reading mt-4 text-lead text-ink-soft">
ResearchX runs under your account — your research library,
reports, and settings are yours alone.
</p>
</header>
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email" className="block text-body font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
placeholder="you@example.org"
className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
/>
<label htmlFor="password" className="mt-6 block text-body font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••••••"
className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
/>
{error !== null && (
<p role="alert" className="mt-4 text-small text-danger">
{error}
</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="mt-8 rounded-pill bg-accent px-7 py-3 text-body font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? "Signing in…" : "Sign in"}
</button>
</form>
<p className="mt-block text-caption text-ink-faint">
First run? The first account created becomes the administrator.
</p>
</div>
);
}
@@ -0,0 +1,101 @@
/**
* SessionGate tests (T-1.10, REQ-69) — the live-mode route guard.
*
* The gate consults the boundary's getSession() on mount:
* - live mode + no session → redirect to /login
* - live mode + session → content renders
* - live mode + /login route → never gated (the login screen itself)
* - mock mode → no getSession call at all, content
* renders immediately (REQ-31/REQ-54)
*
* Mode is a module constant resolved from env at import time, so this
* suite exercises the gate's decision logic with the boundary stubbed
* per case; the mock-mode fast path is pinned against the REAL
* binding (the default mock singleton).
*/
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionGate } from "@/components/session-gate";
import type { ApiClient } from "@/lib/api/client";
const replaceMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: replaceMock }),
usePathname: () => "/ask",
}));
const getSessionMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/client", async (importOriginal) => {
const actual = await importOriginal<
Promise<typeof import("@/lib/api/client")>
>();
return {
...actual,
liveMode: true, // the gate only acts in live mode
api: {
...(actual.api as unknown as ApiClient),
getSession: () => getSessionMock(),
} as unknown as ApiClient,
};
});
const SESSION_USER = {
user_id: "abcd2345",
email: "owner@example.org",
display_name: "Owner",
role: "admin",
active: true,
created_at: "2026-09-29T12:00:00Z",
};
describe("SessionGate (T-1.10, REQ-69)", () => {
beforeEach(() => {
replaceMock.mockClear();
getSessionMock.mockReset();
});
afterEach(cleanup);
it("redirects an unauthenticated visitor to /login before content renders", async () => {
// Persistent mock: React StrictMode double-invokes effects in tests,
// so the boundary stub must answer every call the same way.
getSessionMock.mockResolvedValue(null);
render(
<SessionGate>
<p>Protected screen</p>
</SessionGate>,
);
// Guard content never flashes for an unauthenticated visitor.
expect(screen.queryByText("Protected screen")).toBeNull();
await vi.waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/login");
});
expect(getSessionMock).toHaveBeenCalled();
});
it("renders the shell once a session resolves", async () => {
getSessionMock.mockResolvedValue(SESSION_USER);
render(
<SessionGate>
<p>Protected screen</p>
</SessionGate>,
);
expect(await screen.findByText("Protected screen")).toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalled();
});
it("fails closed when the session probe errors", async () => {
getSessionMock.mockRejectedValue(new Error("backend unreachable"));
render(
<SessionGate>
<p>Protected screen</p>
</SessionGate>,
);
await vi.waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/login");
});
expect(screen.queryByText("Protected screen")).toBeNull();
});
});
+59
View File
@@ -0,0 +1,59 @@
"use client";
/**
* SessionGate (T-1.10, REQ-69) — the live-mode route guard.
*
* A session bootstrap in the app shell: on mount it asks the boundary
* for the current session; in live mode an unauthenticated user is
* redirected to /login. Mock mode skips the check entirely (the mock
* is a standalone dev/test fixture with no auth — REQ-31/REQ-54), so
* its behavior stays byte-for-byte unchanged.
*
* While the session resolves the screen is blank rather than flashing
* protected content at an unauthenticated visitor (no data ever
* renders before the guard clears).
*/
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { api, liveMode } from "@/lib/api/client";
export function SessionGate({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname() ?? "";
// The login screen itself is never gated (nor redirected).
const isLoginRoute = pathname === "/login";
// Mock mode never gates: render immediately, no getSession() call.
const [ready, setReady] = useState(!liveMode || isLoginRoute);
useEffect(() => {
if (!liveMode || isLoginRoute) return;
let cancelled = false;
api
.getSession()
.then((session) => {
if (cancelled) return;
if (session === null) {
router.replace("/login");
} else {
setReady(true);
}
})
.catch(() => {
// A guard that cannot ask the backend fails closed.
if (!cancelled) router.replace("/login");
});
return () => {
cancelled = true;
};
}, [router, isLoginRoute]);
if (!ready) {
return (
<div className="flex min-h-dvh items-center justify-center">
<p className="text-caption text-ink-faint">Loading…</p>
</div>
);
}
return <>{children}</>;
}
@@ -0,0 +1,146 @@
/**
* UserMenu session display tests (T-1.10, REQ-69) — the avatar menu
* shows the REAL profile in live mode (display name + email from
* getSession) and keeps the fixture in mock mode, plus Logout wiring.
*
* The menu consults the boundary twice: getUserProfile() for the
* fixture display and getSession() for the live account. This suite
* stubs both through the ApiClient surface, driving live mode with a
* resolved session and mock mode with the real mock binding.
*/
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { UserMenu } from "@/components/user-menu";
import type { ApiClient, UserProfile } from "@/lib/api/client";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
usePathname: () => "/ask",
}));
// Boundary + mode injection: the suite swaps the module's `api` and
// `liveMode` exports per case (the menu reads both). The mock's `api`
// delegates to state.api at CALL time so re-assignment per test works.
const state = vi.hoisted(() => ({
api: {} as Record<string, unknown>,
liveMode: false,
}));
vi.mock("@/lib/api/client", async (importOriginal) => {
const actual = await importOriginal<
Promise<typeof import("@/lib/api/client")>
>();
return {
...actual,
api: new Proxy(
{},
{
get: (_target, prop) => {
const handler = state.api[prop as string];
return typeof handler === "function"
? (handler as (...args: unknown[]) => unknown).bind(state.api)
: handler;
},
},
),
// Getter export: the menu reads liveMode at render time, so the
// per-test assignment below must be visible.
get liveMode() {
return state.liveMode;
},
};
});
const SESSION_USER: UserProfile = {
user_id: "abcd2345",
email: "owner@example.org",
display_name: "Ada Owner",
role: "admin",
active: true,
created_at: "2026-09-29T12:00:00Z",
};
/** Boundary-shaped stub with an injectable live-mode flag + session. */
function makeApi(overrides: {
session: UserProfile | null;
logout: () => Promise<void>;
}): ApiClient {
return {
login: vi.fn(),
logout: vi.fn(overrides.logout),
getSession: vi.fn().mockResolvedValue(overrides.session),
listUsers: vi.fn(),
createUser: vi.fn(),
deactivateUser: vi.fn(),
getUserProfile: vi.fn().mockResolvedValue({
name: "Researcher",
initials: "RS",
tagline: "Local workspace — single user",
}),
} as unknown as ApiClient;
}
describe("UserMenu session display (T-1.10, REQ-69)", () => {
beforeEach(() => {
pushMock.mockClear();
});
afterEach(cleanup);
it("live mode shows the real session profile — display name + email", async () => {
state.api = makeApi({ session: SESSION_USER, logout: vi.fn() }) as unknown as Record<string, unknown>;
state.liveMode = true;
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
expect(await screen.findByText("Ada Owner")).toBeInTheDocument();
expect(screen.getByText("owner@example.org")).toBeInTheDocument();
// The avatar disc carries the real initials.
expect(screen.getByText("AO")).toBeInTheDocument();
// Never the fixture name in live mode with a resolved session.
expect(screen.queryByText("Researcher")).toBeNull();
});
it("mock mode keeps the fixture profile unchanged", async () => {
state.api = makeApi({ session: SESSION_USER, logout: vi.fn() }) as unknown as Record<string, unknown>;
state.liveMode = false;
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
expect(await screen.findByText("Researcher")).toBeInTheDocument();
expect(
screen.getByText("Local workspace — single user"),
).toBeInTheDocument();
});
it("logout calls the boundary then routes to /login", async () => {
const logout = vi.fn().mockResolvedValue(undefined);
const onDismiss = vi.fn();
state.api = makeApi({ session: SESSION_USER, logout }) as unknown as Record<string, unknown>;
state.liveMode = true;
const user = userEvent.setup();
render(<UserMenu onNavigate={() => {}} onDismiss={onDismiss} />);
await user.click(
await screen.findByRole("menuitem", { name: /Log out/i }),
);
await vi.waitFor(() => {
expect(logout).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith("/login");
expect(onDismiss).toHaveBeenCalled();
});
});
it("a failed logout still routes away (the cookie dies server-side)", async () => {
const logout = vi.fn().mockRejectedValue(new Error("network down"));
state.api = makeApi({ session: SESSION_USER, logout }) as unknown as Record<string, unknown>;
state.liveMode = true;
const user = userEvent.setup();
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
await user.click(
await screen.findByRole("menuitem", { name: /Log out/i }),
);
await vi.waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
});
+79 -14
View File
@@ -1,16 +1,19 @@
"use client";
/**
* UserMenu (T-5.2, REQ-66) — the avatar button's dropdown.
* UserMenu (T-5.2, REQ-66; T-1.10, REQ-69) — the avatar button's dropdown.
*
* Local single-user profile (no auth — single-user constraint), links to
* Settings and About (the settings page's about section). Escape and
* outside clicks dismiss. Rendered as a menu for assistive tech.
* Shows the profile: the real session user (display name + email via
* getSession) in live mode, the local fixture in mock mode — and a
* Logout action (revokes the session through the boundary, routes to
* /login). Escape and outside clicks dismiss. Rendered as a menu for
* assistive tech.
*/
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, type RefObject } from "react";
import { api, type UserProfile } from "@/lib/api/client";
import { api, liveMode, type DisplayProfile, type UserProfile } from "@/lib/api/client";
export function UserMenu({
onNavigate,
@@ -22,7 +25,10 @@ export function UserMenu({
/** The avatar button — clicks on it toggle, not dismiss-then-reopen. */
toggleRef?: RefObject<HTMLButtonElement | null>;
}) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const router = useRouter();
const [profile, setProfile] = useState<DisplayProfile | null>(null);
const [session, setSession] = useState<UserProfile | null>(null);
const [loggingOut, setLoggingOut] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -35,11 +41,52 @@ export function UserMenu({
.catch(() => {
/* fallback initials render below */
});
// The real session user drives the live-mode display (T-1.10);
// mock mode ignores it and keeps the fixture.
api
.getSession()
.then((user) => {
if (!cancelled) setSession(user);
})
.catch(() => {
/* menu falls back to the fixture display */
});
return () => {
cancelled = true;
};
}, []);
// Live mode with a resolved session shows the real account; mock
// mode (and any unresolved session) shows the fixture unchanged.
const showSession = liveMode && session !== null;
const name = showSession
? (session as UserProfile).display_name
: (profile?.name ?? "Researcher");
const detail = showSession
? (session as UserProfile).email
: (profile?.tagline ?? "Local workspace");
const initials = showSession
? initialsOf((session as UserProfile).display_name)
: (profile?.initials ?? "··");
// Logout: revoke the session through the boundary, then land on
// /login (live mode). Mock mode keeps the identical control — its
// logout is a local no-op that still routes, so the surface behaves
// the same in both bindings.
const handleLogout = () => {
if (loggingOut) return;
setLoggingOut(true);
api
.logout()
.catch(() => {
/* a failed revoke still leaves the app — the cookie dies server-side soonest */
})
.finally(() => {
onDismiss();
router.push("/login");
});
};
// Escape closes; outside clicks close. Focus stays in the header.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
@@ -72,15 +119,11 @@ export function UserMenu({
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-pill bg-accent text-small font-semibold text-paper-raised"
>
{profile?.initials ?? "··"}
{initials}
</span>
<div>
<p className="text-small font-medium text-ink">
{profile?.name ?? "Researcher"}
</p>
<p className="text-caption text-ink-faint">
{profile?.tagline ?? "Local workspace"}
</p>
<p className="text-small font-medium text-ink">{name}</p>
<p className="text-caption text-ink-faint">{detail}</p>
</div>
</div>
@@ -105,11 +148,33 @@ export function UserMenu({
About &amp; version
</Link>
</li>
<li>
<button
type="button"
role="menuitem"
onClick={handleLogout}
disabled={loggingOut}
className="mt-1 w-full rounded-card px-2 py-2 text-left text-small text-ink transition-colors hover:bg-paper-sunken disabled:cursor-not-allowed disabled:opacity-40"
>
{loggingOut ? "Signing out…" : "Log out"}
</button>
</li>
</ul>
<p className="mt-3 border-t border-hairline pt-3 text-caption text-ink-faint">
Local profile — accounts and sync land post-MVP.
{showSession
? `Signed in as ${session?.role} — sessions slide for 14 days.`
: "Local profile — accounts and sync land post-MVP."}
</p>
</div>
);
}
/** Two-letter initials from a display name ("Dev User" → "DU"). */
function initialsOf(displayName: string): string {
const words = displayName.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "··";
const first = words[0]?.[0] ?? "";
const second = words[1]?.[0] ?? words[0]?.[1] ?? "";
return `${first}${second}`.toUpperCase();
}
+69 -2
View File
@@ -10,7 +10,9 @@
import type {
CitationLedger,
ConnectionSlot,
CreateUserInput,
DialValues,
DisplayProfile,
InboxItem,
Methodology,
ProjectGraph,
@@ -33,7 +35,9 @@ export type {
Citation,
CitationLedger,
ConnectionSlot,
CreateUserInput,
DialValues,
DisplayProfile,
GraphEdge,
GraphNode,
InboxItem,
@@ -153,8 +157,11 @@ export interface ApiClient {
*/
getReportConnections(): Promise<ReportConnection[]>;
/** The local single-user profile behind the avatar (REQ-66). */
getUserProfile(): Promise<UserProfile>;
/**
* The menu/display profile behind the avatar (REQ-66). In live mode
* this derives from the session user; in mock mode it is the fixture.
*/
getUserProfile(): Promise<DisplayProfile>;
/** Adapter-slot connection status for the shell + settings (REQ-65). */
getConnectionStatus(): Promise<ConnectionSlot[]>;
@@ -191,8 +198,68 @@ export interface ApiClient {
draft: string,
answers: Record<string, string>,
): Promise<RefinedQuestion>;
/* ---------------------------------------------------------------- */
/* v0.3 P1 session surface (C3-18 exhaustive list — REQ-69, REQ-70). */
/* ---------------------------------------------------------------- */
/**
* Authenticate with email + password (POST /api/auth/login — sets
* the httpOnly session cookie server-side). Rejects with a typed
* AuthError on 401 (uniform "invalid email or password").
*/
login(email: string, password: string): Promise<UserProfile>;
/** Revoke the current session (POST /api/auth/logout). */
logout(): Promise<void>;
/**
* The current session's user, or null when unauthenticated
* (GET /api/auth/me — 401 maps to null, never a throw).
*/
getSession(): Promise<UserProfile | null>;
/** Admin hook: list all users (GET /api/admin/users — REQ-70). */
listUsers(): Promise<UserProfile[]>;
/** Admin hook: create a user with an admin-set password (POST /api/admin/users). */
createUser(input: CreateUserInput): Promise<UserProfile>;
/**
* Admin hook: deactivate a user — kills their sessions, retains
* their data (POST /api/admin/users/{user_id}/deactivate).
*/
deactivateUser(user_id: string): Promise<UserProfile>;
}
/**
* Typed auth failure (T-1.10, REQ-69) — thrown by `login` on 401.
* The backend answers every failure mode uniformly ("invalid email or
* password" — no user enumeration); the client carries the same
* honesty through to the login screen.
*/
export class AuthError extends Error {
/** HTTP status of the failed auth request (401 for bad credentials). */
readonly status: number;
constructor(message = "invalid email or password", status = 401) {
super(message);
this.name = "AuthError";
this.status = status;
}
}
/**
* Whether the app is bound to the live backend (auth applies) or to
* the mock fixture (dev/test — no login, no redirect; REQ-31/REQ-54).
* The login route guard and session bootstrap consult this flag so
* mock mode's zero-auth behavior stays byte-for-byte unchanged.
*/
export const liveMode: boolean =
(process.env.NEXT_PUBLIC_RESEARCHX_API_MODE ??
process.env.RESEARCHX_API_MODE ??
"mock") === "live";
/**
* The app-wide singleton. v0.1: MockApiClient (fixture data + scripted
* event replay, zero API keys). v0.2 P3 (REQ-26/REQ-54): the binding
+7 -2
View File
@@ -161,11 +161,16 @@ describe("E2E happy path — real stack, defaults only (REQ-17)", () => {
}
}, 55_000);
});
// The engine's terminal signal arrived on the stream: onDone only
// fires after the run_done event (the stream closes on it).
expect(done).toBe(true);
expect(events).toContain("status");
const final = await client.getRun(run.id);
expect(final.status).toBe("done");
// P-6 (registry pruning, T-1.8): the terminal run's registry entry
// is dropped once its last SSE subscriber leaves. A run GET after
// the stream 404s — documented acceptable; the trace persists on
// disk (the events-404-after-prune class, pinned here end to end).
await expect(client.getRun(run.id)).rejects.toThrow(/failed to load run/);
expect(userActions).toBeLessThanOrEqual(3);
}, 90_000);
+391
View File
@@ -4,6 +4,126 @@
*/
export interface paths {
"/healthz": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Healthz */
get: operations["healthz_healthz_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/register": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Register */
post: operations["register_api_auth_register_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Login */
post: operations["login_api_auth_login_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Logout */
post: operations["logout_api_auth_logout_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Me */
get: operations["me_api_auth_me_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Users */
get: operations["list_users_api_admin_users_get"];
put?: never;
/** Create User */
post: operations["create_user_api_admin_users_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/users/{user_id}/deactivate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Deactivate User */
post: operations["deactivate_user_api_admin_users__user_id__deactivate_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/config": {
parameters: {
query?: never;
@@ -195,6 +315,23 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** AdminCreateUserBody */
AdminCreateUserBody: {
/** Email */
email: string;
/** Password */
password: string;
/**
* Display Name
* @default
*/
display_name: string;
/**
* Role
* @default user
*/
role: string;
};
/** ApproveBody */
ApproveBody: {
/** Plan */
@@ -240,6 +377,13 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** LoginBody */
LoginBody: {
/** Email */
email: string;
/** Password */
password: string;
};
/** MethodologyCard */
MethodologyCard: {
/** Slug */
@@ -261,6 +405,18 @@ export interface components {
[key: string]: unknown;
};
};
/** RegisterBody */
RegisterBody: {
/** Email */
email: string;
/** Password */
password: string;
/**
* Display Name
* @default
*/
display_name: string;
};
/** RunSummary */
RunSummary: {
/** Id */
@@ -278,6 +434,27 @@ export interface components {
/** Started At */
started_at: string;
};
/**
* UserPublic
* @description A user as exposed on the wire — no password_hash, ever.
*/
UserPublic: {
/** User Id */
user_id: string;
/** Email */
email: string;
/** Display Name */
display_name: string;
/** Role */
role: string;
/** Active */
active: boolean;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** ValidationError */
ValidationError: {
/** Location */
@@ -296,6 +473,220 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
healthz_healthz_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
register_api_auth_register_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RegisterBody"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserPublic"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
login_api_auth_login_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LoginBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserPublic"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
logout_api_auth_logout_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: boolean;
};
};
};
};
};
me_api_auth_me_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserPublic"];
};
};
};
};
list_users_api_admin_users_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserPublic"][];
};
};
};
};
create_user_api_admin_users_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AdminCreateUserBody"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserPublic"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
deactivate_user_api_admin_users__user_id__deactivate_post: {
parameters: {
query?: never;
header?: never;
path: {
user_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserPublic"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
config_status_api_config_get: {
parameters: {
query?: never;
+120
View File
@@ -17,15 +17,18 @@ import createClient from "openapi-fetch";
import type { paths } from "@/lib/api/generated/schema";
import type {
CitationLedger,
CreateUserInput,
Methodology,
Report,
ResearchRun,
TraceEvent,
UserProfile,
} from "@/lib/types";
import type {
CreateRunInput,
RunEventSubscription,
} from "@/lib/api/client";
import { AuthError } from "@/lib/api/client";
import { MockApiClient } from "@/lib/api/mock/mock-client";
function baseUrl(): string {
@@ -34,6 +37,12 @@ function baseUrl(): string {
);
}
/**
* The uniform CSRF header value for every mutating request (C3-05):
* the backend guard requires `X-ResearchX-Request` on non-GET calls.
*/
const CSRF_HEADER = "X-ResearchX-Request";
type BackendRun = {
id: string;
status: string;
@@ -69,6 +78,117 @@ export class GeneratedApiClient extends MockApiClient {
});
}
/* ---------------------------------------------------------------- */
/* Session surface (T-1.10, REQ-69/REQ-70). Fetch discipline: */
/* - `credentials: "include"` on EVERY call — the session cookie */
/* rides along; */
/* - `X-ResearchX-Request: "1"` on every mutation (C3-05). */
/* ---------------------------------------------------------------- */
/** Map the backend's UserPublic to the frontend UserProfile shape. */
#toProfile(user: {
user_id: string;
email: string;
display_name: string;
role: string;
active: boolean;
created_at: string;
}): UserProfile {
return {
user_id: user.user_id,
email: user.email,
display_name: user.display_name,
role: user.role,
active: user.active,
created_at: user.created_at,
};
}
override async login(email: string, password: string): Promise<UserProfile> {
const { data, error, response } = await this.#client.POST(
"/api/auth/login",
{
body: { email, password },
credentials: "include",
headers: { [CSRF_HEADER]: "1" },
},
);
if (error || !data) {
// Uniform 401 — the backend never enumerates users; neither do we.
if (response.status === 401) {
throw new AuthError("invalid email or password", 401);
}
throw new AuthError("login failed", response.status);
}
return this.#toProfile(data);
}
override async logout(): Promise<void> {
const { error } = await this.#client.POST("/api/auth/logout", {
credentials: "include",
headers: { [CSRF_HEADER]: "1" },
});
if (error) {
throw new Error("live: logout failed");
}
}
override async getSession(): Promise<UserProfile | null> {
const { data, error } = await this.#client.GET("/api/auth/me", {
credentials: "include",
});
if (error || !data) {
// 401 → null: "no session" is a state, not an error.
return null;
}
return this.#toProfile(data);
}
override async listUsers(): Promise<UserProfile[]> {
const { data, error } = await this.#client.GET("/api/admin/users", {
credentials: "include",
});
if (error || !data) {
throw new Error("live: failed to list users");
}
return data.map((user) => this.#toProfile(user));
}
override async createUser(input: CreateUserInput): Promise<UserProfile> {
const { data, error, response } = await this.#client.POST(
"/api/admin/users",
{
body: {
email: input.email,
password: input.password,
display_name: input.display_name ?? "",
role: input.role ?? "user",
},
credentials: "include",
headers: { [CSRF_HEADER]: "1" },
},
);
if (error || !data) {
throw new Error(`live: failed to create user (${response.status})`);
}
return this.#toProfile(data);
}
override async deactivateUser(user_id: string): Promise<UserProfile> {
const { data, error } = await this.#client.POST(
"/api/admin/users/{user_id}/deactivate",
{
params: { path: { user_id } },
credentials: "include",
headers: { [CSRF_HEADER]: "1" },
},
);
if (error || !data) {
throw new Error(`live: failed to deactivate user ${user_id}`);
}
return this.#toProfile(data);
}
override async getMethodologies(): Promise<Methodology[]> {
const { data, error } = await this.#client.GET("/api/methodologies");
if (error || !data) {
+90 -1
View File
@@ -7,6 +7,7 @@
*/
import type { ApiClient, CreateRunInput } from "@/lib/api/client";
import { AuthError } from "@/lib/api/client";
import {
buildSuggestedQueries,
composeRefinedQuestion,
@@ -33,6 +34,8 @@ import {
import type {
CitationLedger,
ConnectionSlot,
CreateUserInput,
DisplayProfile,
InboxItem,
Methodology,
ProjectGraph,
@@ -274,7 +277,7 @@ export class MockApiClient implements ApiClient {
}
/** The local single-user profile behind the avatar (REQ-66). */
async getUserProfile(): Promise<UserProfile> {
async getUserProfile(): Promise<DisplayProfile> {
return structuredClone(userProfileFixture);
}
@@ -334,6 +337,92 @@ export class MockApiClient implements ApiClient {
return composeRefinedQuestion(draft, answers);
}
/* ---------------------------------------------------------------- */
/* v0.3 P1 session surface — mock-local (T-1.10, REQ-69/REQ-70). */
/* The mock stays a standalone zero-backend fixture: login accepts */
/* any non-empty credentials and swaps an in-memory session in; */
/* the dev user mirrors the backend's synthetic dev admin */
/* (DEV_USER_ID "dev00000", guard.py). */
/* ---------------------------------------------------------------- */
#session: UserProfile | null = null;
/** The mock dev user — same synthetic admin the backend's dev guard resolves. */
static readonly DEV_USER: UserProfile = {
user_id: "dev00000",
email: "dev@localhost",
display_name: "Dev User",
role: "admin",
active: true,
created_at: "2026-01-01T00:00:00Z",
};
/** Small fixture user set for the admin hooks (REQ-70). */
static readonly USERS: UserProfile[] = [
MockApiClient.DEV_USER,
{
user_id: "abcd2345",
email: "colleague@example.org",
display_name: "Fixture Colleague",
role: "user",
active: true,
created_at: "2026-02-01T00:00:00Z",
},
];
/** Local "login" — any non-empty credentials open the dev session. */
async login(email: string, password: string): Promise<UserProfile> {
if (email.trim() === "" || password === "") {
throw new AuthError();
}
this.#session = structuredClone(MockApiClient.DEV_USER);
return structuredClone(MockApiClient.DEV_USER);
}
/** Local "logout" — clears the in-memory session. */
async logout(): Promise<void> {
this.#session = null;
}
/**
* The mock session is implicitly open (dev fixture): the dev user
* when a "login" happened this session, otherwise still the dev
* user — the mock never gates the app behind a login (REQ-31).
*/
async getSession(): Promise<UserProfile | null> {
return structuredClone(this.#session ?? MockApiClient.DEV_USER);
}
/** Admin hooks — small fixture arrays (REQ-70). */
async listUsers(): Promise<UserProfile[]> {
return structuredClone(MockApiClient.USERS);
}
async createUser(input: CreateUserInput): Promise<UserProfile> {
const created: UserProfile = {
user_id: "newu0001",
email: input.email,
display_name: input.display_name ?? input.email.split("@")[0] ?? "New User",
role: input.role ?? "user",
active: true,
created_at: "2026-09-29T00:00:00Z",
};
MockApiClient.USERS.push(created);
return structuredClone(created);
}
async deactivateUser(user_id: string): Promise<UserProfile> {
const user = MockApiClient.USERS.find((u) => u.user_id === user_id);
if (!user) {
throw new Error(`mock: unknown user ${user_id}`);
}
user.active = false;
if (this.#session?.user_id === user_id) {
this.#session = null; // deactivation kills sessions
}
return structuredClone(user);
}
/** The scripted run for the prototype's demo question. */
#defaultRun(): ResearchRun {
return buildScriptedRun({
+2 -2
View File
@@ -9,9 +9,9 @@
import type { Settings } from "@/lib/types";
import type {
DisplayProfile,
ReportConnection,
ReportLibrary,
UserProfile,
} from "@/lib/types";
/* ------------------------------------------------------------------ */
@@ -200,7 +200,7 @@ export const connectionStatusFixture: ConnectionSlot[] = [
/* User profile (REQ-66) */
/* ------------------------------------------------------------------ */
export const userProfileFixture: UserProfile = {
export const userProfileFixture: DisplayProfile = {
name: "Researcher",
initials: "RS",
tagline: "Local workspace — single user",
@@ -0,0 +1,255 @@
/**
* Session-surface boundary tests (T-1.10, REQ-69, REQ-70).
*
* The v0.3 P1 ApiClient additions — login / logout / getSession plus
* the admin hooks — contract-tested against BOTH bindings:
* - the mock binding (standalone, zero backend — REQ-31/REQ-54), and
* - the live binding against a stubbed global fetch (no network, no
* keys), asserting the real routes, the CSRF header on every
* mutation (C3-05), and credentials:"include" cookie discipline.
*/
import { describe, expect, it } from "vitest";
import { AuthError, type ApiClient } from "@/lib/api/client";
import { MockApiClient } from "@/lib/api/mock/mock-client";
import { GeneratedApiClient } from "@/lib/api/live-client";
/* ------------------------------------------------------------------ */
/* Mock binding — standalone session fixture */
/* ------------------------------------------------------------------ */
describe("MockApiClient session surface (REQ-69/REQ-70)", () => {
it("login accepts any non-empty credentials and returns the dev admin", async () => {
const client: ApiClient = new MockApiClient();
const user = await client.login("dev@localhost", "anything");
expect(user.user_id).toBe("dev00000");
expect(user.role).toBe("admin");
expect(user.active).toBe(true);
});
it("getSession returns the dev user (mock is never login-gated)", async () => {
const client: ApiClient = new MockApiClient();
const session = await client.getSession();
expect(session?.user_id).toBe("dev00000");
});
it("logout clears the session; getSession still reports the dev fixture", async () => {
const client: ApiClient = new MockApiClient();
await client.login("dev@localhost", "pw");
await client.logout();
// The mock dev user persists as the implicit session (REQ-31)…
expect((await client.getSession())?.user_id).toBe("dev00000");
});
it("login rejects empty credentials with a typed AuthError", async () => {
const client: ApiClient = new MockApiClient();
await expect(client.login("", "")).rejects.toBeInstanceOf(AuthError);
});
it("admin hooks return small fixture arrays and honor deactivate", async () => {
const client: ApiClient = new MockApiClient();
const users = await client.listUsers();
expect(users.length).toBeGreaterThanOrEqual(2);
expect(users[0]?.role).toBe("admin");
const created = await client.createUser({
email: "new@example.org",
password: "not-a-real-secret",
});
expect(created.email).toBe("new@example.org");
expect(created.active).toBe(true);
const deactivated = await client.deactivateUser(created.user_id);
expect(deactivated.active).toBe(false);
});
});
/* ------------------------------------------------------------------ */
/* Live binding — real routes against a stubbed fetch */
/* ------------------------------------------------------------------ */
type RouteHandler = (url: string, init?: RequestInit) => { status: number; body: unknown };
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
// openapi-fetch builds a Request and hands it to fetch — surface the
// request's own headers + credentials back to the route handler so
// tests can assert the CSRF header and cookie discipline per call.
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : undefined;
const url = request ? request.url : String(input);
const sentInit: RequestInit | undefined = request
? {
...init,
method: request.method,
credentials: request.credentials,
headers: request.headers,
}
: init;
for (const [prefix, handler] of Object.entries(routes)) {
if (url.startsWith(prefix)) {
const { status, body } = handler(url, sentInit);
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
}
return new Response(JSON.stringify(body404()), { status: 404 });
}) as typeof globalThis.fetch;
}
const body404 = (): unknown => ({ detail: "not found" });
const backendUser = {
user_id: "abcd2345",
email: "owner@example.org",
display_name: "Owner",
role: "admin",
active: true,
created_at: "2026-09-29T12:00:00Z",
};
/** Headers actually sent on the last mutating call (CSRF + cookie assertions). */
function mutationCapture(): {
headersOf: (init?: RequestInit) => Record<string, string>;
} {
return {
// Duck-typed: jsdom + Node fetch disagree on the Headers realm, so
// `instanceof Headers` is false across the boundary — check shape.
headersOf: (init?: RequestInit) => {
const raw = init?.headers;
const record: Record<string, string> = {};
if (
typeof raw === "object" &&
raw !== null &&
typeof (raw as Headers).forEach === "function"
) {
(raw as Headers).forEach((v, k) => {
record[k] = v;
});
} else if (Array.isArray(raw)) {
for (const [k, v] of raw) record[String(k)] = String(v);
} else if (raw) {
Object.assign(record, raw);
}
return record;
},
};
}
describe("GeneratedApiClient session surface (live binding, REQ-69/REQ-70)", () => {
it("login POSTs /api/auth/login with the CSRF header + credentials", async () => {
const { headersOf } = mutationCapture();
const routes = {
"http://127.0.0.1:8000/api/auth/login": (_url: string, init?: RequestInit) => {
const headers = headersOf(init);
expect(headers["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
return { status: 200, body: backendUser };
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
const user = await client.login("owner@example.org", "correct-horse");
expect(user.user_id).toBe("abcd2345");
expect(user.email).toBe("owner@example.org");
expect(user.role).toBe("admin");
});
it("login 401 → typed AuthError with the uniform message", async () => {
const routes = {
"http://127.0.0.1:8000/api/auth/login": () => ({
status: 401,
body: { detail: "invalid email or password" },
}),
};
const client = new GeneratedApiClient(stubRoutes(routes));
const error = await client.login("owner@example.org", "wrong").catch((e: unknown) => e);
expect(error).toBeInstanceOf(AuthError);
expect((error as AuthError).status).toBe(401);
expect((error as AuthError).message).toBe("invalid email or password");
});
it("logout POSTs /api/auth/logout with the CSRF header", async () => {
const { headersOf } = mutationCapture();
const routes = {
"http://127.0.0.1:8000/api/auth/logout": (_url: string, init?: RequestInit) => {
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
return { status: 200, body: { ok: true } };
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
await expect(client.logout()).resolves.toBeUndefined();
});
it("getSession maps 200 → UserProfile; 401 → null (never a throw)", async () => {
const ok = new GeneratedApiClient(
stubRoutes({
"http://127.0.0.1:8000/api/auth/me": () => ({
status: 200,
body: backendUser,
}),
}),
);
expect((await ok.getSession())?.user_id).toBe("abcd2345");
const unauthed = new GeneratedApiClient(
stubRoutes({
"http://127.0.0.1:8000/api/auth/me": () => ({
status: 401,
body: { detail: "authentication required" },
}),
}),
);
expect(await unauthed.getSession()).toBeNull();
});
it("admin hooks hit the protected routes with CSRF on mutations", async () => {
const { headersOf } = mutationCapture();
const routes: Record<string, RouteHandler> = {
// Registered BEFORE the collection route: the deactivate path
// startswith-matches "/api/admin/users" too.
"http://127.0.0.1:8000/api/admin/users/abcd2345/deactivate": (
_url: string,
init?: RequestInit,
) => {
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
return { status: 200, body: { ...backendUser, active: false } };
},
"http://127.0.0.1:8000/api/admin/users": (url: string, init?: RequestInit) => {
if (url.endsWith("/api/admin/users") && init?.method !== "POST") {
return { status: 200, body: [backendUser] };
}
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
return {
status: 201,
body: {
...backendUser,
user_id: "newaaa01",
email: "second@example.org",
role: "user",
},
};
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
const users = await client.listUsers();
expect(users).toHaveLength(1);
expect(users[0]?.user_id).toBe("abcd2345");
const created = await client.createUser({
email: "second@example.org",
password: "not-a-real-secret",
role: "user",
});
expect(created.user_id).toBe("newaaa01");
expect(created.role).toBe("user");
const deactivated = await client.deactivateUser("abcd2345");
expect(deactivated.active).toBe(false);
});
});
+35 -3
View File
@@ -397,9 +397,13 @@ export interface Settings {
theme: "paper";
}
/** The local single-user profile behind the avatar button (REQ-66). */
export interface UserProfile {
/** Display name — local profile, no auth (single-user constraint). */
/**
* The menu/display profile behind the avatar button (REQ-66).
* v0.2 prototype shape — name + initials + tagline for the local
* single-user workspace. Kept for the getUserProfile() surface.
*/
export interface DisplayProfile {
/** Display name. */
name: string;
/** Two-letter initials rendered inside the avatar disc. */
initials: string;
@@ -407,6 +411,34 @@ export interface UserProfile {
tagline: string;
}
/* ------------------------------------------------------------------ */
/* Auth session (P1 vocabulary — REQ-69, REQ-70) */
/* ------------------------------------------------------------------ */
/**
* The authenticated user as the wire exposes them (T-1.10, REQ-69).
* Mirrors the backend's UserPublic: no password hash, ever.
*/
export interface UserProfile {
/** 8-char backend slug, e.g. "dev00000". */
user_id: string;
email: string;
display_name: string;
/** "admin" | "user". */
role: string;
active: boolean;
/** ISO timestamp of account creation. */
created_at: string;
}
/** Input to the admin user-create hook (REQ-70). */
export interface CreateUserInput {
email: string;
password: string;
display_name?: string;
role?: "admin" | "user";
}
/* ------------------------------------------------------------------ */
/* Ask intelligence (P7 vocabulary — REQ-67, REQ-68) */
/* ------------------------------------------------------------------ */