Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 669584cdb4 | |||
| 36d428f569 | |||
| 7598b1707c | |||
| ce9d1baecf | |||
| c31b61be7f | |||
| 79d83bd188 | |||
| ad404b6659 | |||
| 690925c6d1 | |||
| b964c60e72 | |||
| 593be870eb | |||
| 8afbf1cd83 | |||
| 1eebe94beb | |||
| 6c85ad2824 | |||
| b72f16a944 | |||
| fd3d6c2e03 | |||
| b485fc0817 | |||
| ccb373dee1 | |||
| ee26ff1658 | |||
| b8b0b68f1b | |||
| 2a61433e6c | |||
| 60f858ab99 | |||
| 1adfa878d7 |
+42
-31
@@ -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).
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"phase": 5,
|
||||
"phase": 3,
|
||||
"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-29T23:20:00Z"
|
||||
}
|
||||
+214
-1
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 | complete (v0.3.2; P0 null-poisoning fixed in verify; P-1/P-2/P-13 closed; production main entrypoint P-3 backend half) |
|
||||
| REQ-76..82 (live surfaces, user-scoped) | v0.3 | P3 | complete (v0.3.3; D-112 standalone client, G-109 honest inbox, P-8 full closure, P-12 dead; verify PASS-WITH-FIXES) |
|
||||
| REQ-75 (delegation-end half) + REQ-83..85 (hosting; live default hosted) | v0.3 | P3/P4 (REQ-75 split per G-103: delegation-end P3 complete, hosted-default P4) | REQ-75 complete in P3 |
|
||||
| REQ-35..51 | v2 backlog (post-MVP milestones) | — | pending |
|
||||
|
||||
@@ -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.
|
||||
@@ -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**: complete (PR #18 squash-merged to milestone/v0.3-hosted ee26ff1, tag v0.3.2, release HTTP 201, phase branch deleted; verify PASS-WITH-FIXES — P0 settings null-poisoning fixed in-phase; 264 backend + 131 frontend tests)
|
||||
|
||||
### Phase 3 — Live Surfaces (user-scoped)
|
||||
- **Description**: Library read-model (GET /api/library), connections + graph (GET /api/library/connections, GET /api/graph), task inbox (GET /api/tasks), suggestions (GET /api/suggestions), refine (POST /api/refine/*, real planner calls), profile (GET/PUT /api/profile), live Reading Room fidelity (structured report, anchored citations, ledger panel). G-002 boundary lifted; frontend live swaps with zero component rewrites (REQ-54).
|
||||
- **Requirements covered**: REQ-76, REQ-77, REQ-78, REQ-79, REQ-80, REQ-81, REQ-82
|
||||
- **Success criteria**: every prototype surface works against real user data in live mode; suggestions/refine driven by real model calls; Reading Room parity with mock; ≤3-click path still green
|
||||
- **Depends on**: 2
|
||||
- **Status**: complete (PR #19 squash-merged to milestone/v0.3-hosted b485fc0, tag v0.3.3, release HTTP 201, phase branch deleted; verify PASS-WITH-FIXES — CSRF test pin P0 fixed; G-108 valve not needed; 314 backend + 164 frontend tests)
|
||||
|
||||
### Phase 4 — Hosting + Real-Inference Default
|
||||
- **Description**: Backend Dockerfile (single worker, non-root), two-service deployment via deploy.sh extension, SSE-safe HAProxy path routing (/api → backend, streaming timeouts), hosted instance runs live mode by default (mock demoted to dev/test fixture), auth guard verified through the public URL.
|
||||
- **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)
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+97
-20
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# ResearchX release deploy (ciagent release system)
|
||||
#
|
||||
# Builds the frontend at a given version tag on the Docker host and
|
||||
# runs it as a container. Idempotent: re-deploying a version replaces
|
||||
# the running container, keeping the previous one as rollback.
|
||||
# Builds the frontend and the backend at a given version tag on the
|
||||
# Docker host and runs them as two containers. Idempotent: re-deploying
|
||||
# a version replaces the running containers, keeping the previous ones
|
||||
# as rollback.
|
||||
#
|
||||
# Usage: bash .ciagent/release/deploy.sh <version> e.g. v0.1.4
|
||||
#
|
||||
@@ -21,8 +22,14 @@ IMAGE_NAME="researchx"
|
||||
CONTAINER_NAME="researchx"
|
||||
CONTAINER_PREV="researchx-prev"
|
||||
HOST_PORT="3000"
|
||||
IMAGE_BACKEND="researchx-backend"
|
||||
CONTAINER_BACKEND="researchx-backend"
|
||||
CONTAINER_BACKEND_PREV="researchx-backend-prev"
|
||||
HOST_PORT_BACKEND="8000"
|
||||
BACKEND_DATA_DIR="/var/lib/researchx"
|
||||
REMOTE_BASE="/opt/researchx"
|
||||
HEALTH_PATH="/ask"
|
||||
BACKEND_HEALTH_PATH="/healthz"
|
||||
HEALTH_RETRIES=15
|
||||
HEALTH_INTERVAL=2
|
||||
# -----------------------------------------------------------------------
|
||||
@@ -38,6 +45,7 @@ VERSION="$1"
|
||||
|
||||
# ---- dependency checks --------------------------------------------------
|
||||
command -v ssh >/dev/null || die "ssh not found" 2
|
||||
command -v uv >/dev/null || die "uv not found (backend gates need it: pip install uv)" 2
|
||||
|
||||
# ---- secrets (env file ONLY) -------------------------------------------
|
||||
[ -f "$SECRETS_FILE" ] || die "secrets file missing: $SECRETS_FILE" 2
|
||||
@@ -156,37 +164,75 @@ if [ "$(git -C "$REPO_ROOT" rev-parse HEAD)" != "$TAG_COMMIT" ]; then
|
||||
WORKTREE=$(mktemp -d)
|
||||
git -C "$REPO_ROOT" worktree add --detach "$WORKTREE" "$TAG_COMMIT" >/dev/null
|
||||
trap 'git -C "$REPO_ROOT" worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
|
||||
BUILD_DIR="$WORKTREE/frontend"
|
||||
BUILD_ROOT="$WORKTREE"
|
||||
else
|
||||
BUILD_DIR="$REPO_ROOT/frontend"
|
||||
BUILD_ROOT="$REPO_ROOT"
|
||||
fi
|
||||
BUILD_DIR="$BUILD_ROOT/frontend"
|
||||
BACKEND_DIR="$BUILD_ROOT/backend"
|
||||
|
||||
(cd "$BUILD_DIR" && pnpm install --frozen-lockfile >/dev/null 2>&1)
|
||||
(cd "$BUILD_DIR" && pnpm typecheck >/dev/null) || die "typecheck failed at $VERSION" 3
|
||||
(cd "$BUILD_DIR" && pnpm lint >/dev/null) || die "lint failed at $VERSION" 3
|
||||
(cd "$BUILD_DIR" && pnpm test >/dev/null 2>&1) || die "tests failed at $VERSION" 3
|
||||
(cd "$BUILD_DIR" && pnpm build >/dev/null 2>&1) || die "build failed at $VERSION" 3
|
||||
log "gates passed: typecheck, lint, test, build"
|
||||
log "frontend gates passed: typecheck, lint, test, build"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Backend gates: the repo's own commands, same as the CI backend job
|
||||
# (.gitea/workflows/ci.yml), run from the committed lockfile.
|
||||
(cd "$BACKEND_DIR" && uv sync --frozen >/dev/null 2>&1) || die "backend dependency sync failed at $VERSION" 3
|
||||
(cd "$BACKEND_DIR" && uv run ruff check src tests >/dev/null) || die "backend ruff failed at $VERSION" 3
|
||||
(cd "$BACKEND_DIR" && uv run mypy src >/dev/null) || die "backend mypy failed at $VERSION" 3
|
||||
(cd "$BACKEND_DIR" && uv run pytest >/dev/null) || die "backend pytest failed at $VERSION" 3
|
||||
log "backend gates passed: ruff, mypy, pytest"
|
||||
|
||||
# ---- remote host prep ----------------------------------------------------
|
||||
log "connecting to $DEPLOY_USER@${DEPLOY_HOST%%:*} and preparing $REMOTE_BASE"
|
||||
SSH "mkdir -p '$REMOTE_BASE/releases/$BASE_VERSION'" || die "cannot reach host or create remote dir" 4
|
||||
SSH "command -v docker >/dev/null" || die "docker not found on host" 4
|
||||
|
||||
# ---- sync source ---------------------------------------------------------
|
||||
log "syncing source to host ($REMOTE_BASE/releases/$BASE_VERSION) via $SYNC_TRANSPORT"
|
||||
sync_source "$BUILD_DIR" "$REMOTE_BASE/releases/$BASE_VERSION" \
|
||||
|| die "source sync failed" 4
|
||||
RELEASE_DIR="$REMOTE_BASE/releases/$BASE_VERSION"
|
||||
log "syncing source to host ($RELEASE_DIR) via $SYNC_TRANSPORT"
|
||||
sync_source "$BUILD_DIR" "$RELEASE_DIR/frontend" || die "frontend source sync failed" 4
|
||||
sync_source "$BACKEND_DIR" "$RELEASE_DIR/backend" || die "backend source sync failed" 4
|
||||
|
||||
# ---- remote build --------------------------------------------------------
|
||||
log "building docker image $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
|
||||
SSH "cd '$REMOTE_BASE/releases/$BASE_VERSION' && \
|
||||
docker build -t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \
|
||||
|| die "docker build failed on host" 4
|
||||
log "building docker images $IMAGE_BACKEND:$VERSION and $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
|
||||
SSH "cd '$RELEASE_DIR/backend' && \
|
||||
docker build -t '$IMAGE_BACKEND:$VERSION' -t '$IMAGE_BACKEND:latest' ." \
|
||||
|| die "backend docker build failed on host" 4
|
||||
SSH "cd '$RELEASE_DIR/frontend' && \
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_RESEARCHX_API_MODE=live \
|
||||
--build-arg NEXT_PUBLIC_API_BASE_URL=\"\" \
|
||||
-t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \
|
||||
|| die "frontend docker build failed on host" 4
|
||||
|
||||
# ---- swap container ------------------------------------------------------
|
||||
log "swapping container (previous kept as $CONTAINER_PREV for rollback)"
|
||||
log "swapping backend container (previous kept as $CONTAINER_BACKEND_PREV for rollback)"
|
||||
SSH "
|
||||
docker rm -f '$CONTAINER_BACKEND_PREV' 2>/dev/null || true
|
||||
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_BACKEND'; then
|
||||
docker rename '$CONTAINER_BACKEND' '$CONTAINER_BACKEND_PREV'
|
||||
docker stop '$CONTAINER_BACKEND_PREV' >/dev/null 2>&1 || true
|
||||
fi
|
||||
mkdir -p '$BACKEND_DATA_DIR'
|
||||
# The container runs as the image's non-root user and a bind mount
|
||||
# SHADOWS the image's own (correctly owned) /var/lib/researchx, so a
|
||||
# root-owned host dir makes every write to the data root (users.json,
|
||||
# sessions.json, keys/) fail with EACCES while /healthz still answers
|
||||
# 200. Take the owner from the image itself.
|
||||
chown -R \$(docker run --rm --entrypoint stat '$IMAGE_BACKEND:$VERSION' -c '%u:%g' /var/lib/researchx) '$BACKEND_DATA_DIR'
|
||||
docker run -d --name '$CONTAINER_BACKEND' --restart unless-stopped \
|
||||
-p '$HOST_PORT_BACKEND:8000' \
|
||||
-v '$BACKEND_DATA_DIR:$BACKEND_DATA_DIR' \
|
||||
-e RESEARCHX_DATA_DIR='$BACKEND_DATA_DIR' \
|
||||
'$IMAGE_BACKEND:$VERSION'
|
||||
" || die "backend docker run failed" 5
|
||||
|
||||
log "swapping frontend container (previous kept as $CONTAINER_PREV for rollback)"
|
||||
SSH "
|
||||
docker rm -f '$CONTAINER_PREV' 2>/dev/null || true
|
||||
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_NAME'; then
|
||||
@@ -195,19 +241,50 @@ SSH "
|
||||
fi
|
||||
docker run -d --name '$CONTAINER_NAME' --restart unless-stopped \
|
||||
-p '$HOST_PORT:3000' '$IMAGE_NAME:$VERSION'
|
||||
" || die "docker run failed" 5
|
||||
" || die "frontend docker run failed" 5
|
||||
|
||||
# ---- health check ---------------------------------------------------------
|
||||
log "health-checking http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
|
||||
log "health-checking backend http://$DEPLOY_HOST:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH (retries: $HEALTH_RETRIES)"
|
||||
i=0
|
||||
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
|
||||
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH'"; then
|
||||
log "backend healthy after $((i + 1)) check(s)"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep "$HEALTH_INTERVAL"
|
||||
done
|
||||
[ "$i" -lt "$HEALTH_RETRIES" ] || die "backend container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
|
||||
|
||||
# The health check above is a GET: it proves the app answers, not that it
|
||||
# can persist. Probe the write path the auth/key stores use, so a
|
||||
# mis-owned data root fails the deploy instead of the first signup.
|
||||
log "checking the backend can write its data root"
|
||||
SSH "docker exec '$CONTAINER_BACKEND' sh -c 'touch \"\$RESEARCHX_DATA_DIR/.write-probe\" && rm -f \"\$RESEARCHX_DATA_DIR/.write-probe\"'" \
|
||||
|| die "backend data root is not writable by the container user" 5
|
||||
|
||||
# Auth smoke (RESEARCH-v03.md section 10 pitfall; PLAN.md T-4.7(1) host-side
|
||||
# leg): the auth-guarded surface answers 401 unauthenticated. This proves
|
||||
# the guarded surface is up through the deployed backend -- it does NOT
|
||||
# discriminate the serving entrypoint (both create_app shapes default
|
||||
# auth_enabled=True; the entrypoint is pinned by the Dockerfile CMD + the
|
||||
# build-stage assert). The live-mode tier marker (an authenticated
|
||||
# /api/config) rides the staged smoke suite, owner directive (D-019).
|
||||
log "checking GET /api/config answers 401 unauthenticated"
|
||||
AUTH_CODE=$(SSH "curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:$HOST_PORT_BACKEND/api/config'")
|
||||
[ "$AUTH_CODE" = "401" ] || die "GET /api/config answered '$AUTH_CODE' unauthenticated, expected 401" 5
|
||||
|
||||
log "health-checking frontend http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
|
||||
i=0
|
||||
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
|
||||
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT$HEALTH_PATH'"; then
|
||||
log "healthy after $((i + 1)) check(s)"
|
||||
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (container $CONTAINER_NAME)"
|
||||
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker start $CONTAINER_NAME'"
|
||||
log "frontend healthy after $((i + 1)) check(s)"
|
||||
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (containers $CONTAINER_NAME, $CONTAINER_BACKEND)"
|
||||
log "backend: http://${DEPLOY_HOST%%:*}:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH (container $CONTAINER_BACKEND)"
|
||||
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME $CONTAINER_BACKEND; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker rename $CONTAINER_BACKEND_PREV $CONTAINER_BACKEND; docker start $CONTAINER_NAME $CONTAINER_BACKEND'"
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep "$HEALTH_INTERVAL"
|
||||
done
|
||||
die "container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
|
||||
die "frontend container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
|
||||
@@ -0,0 +1,59 @@
|
||||
# research.coreci.dev — HAProxy front config for phase 4 (REQ-84, G-104)
|
||||
#
|
||||
# DOCUMENTATION + idempotent template (PLAN.md T-4.5, RESEARCH-v03.md §9):
|
||||
# the host already runs HAProxy 2.8 with the TLS frontend for
|
||||
# research.coreci.dev. This file is the reviewed shape to fold into that
|
||||
# front. It is applied to the live host ONLY on owner deploy directive
|
||||
# (D-019) — never autonomously from a deploy run. The host's live config
|
||||
# is not readable from the authoring seat, so `timeout client` /
|
||||
# `defaults` values below are stated as requirements, not as the
|
||||
# host's current numbers.
|
||||
|
||||
# ---- requirements the host's defaults must already satisfy ----------------
|
||||
# timeout client 3600s # MUST exceed the 15s sse-starlette ping
|
||||
# # interval (RESEARCH-v03.md §9) or idle SSE
|
||||
# # streams are cut mid-run.
|
||||
# keep-alive default ON # keep http-keep-alive; the SSE stream rides it.
|
||||
|
||||
# ---- frontend routes -------------------------------------------------------
|
||||
frontend https-in
|
||||
acl is_api path_beg /api
|
||||
acl is_health path /healthz
|
||||
use_backend backend_api if is_api or is_health
|
||||
default_backend frontend_next
|
||||
|
||||
# G-104 per-IP login rate limit (PLAN.md T-4.5 scope): stick-table on
|
||||
# /api/auth/*. Threshold below is a STARTING VALUE for the
|
||||
# security-engineer review, not a measured traffic figure — rate
|
||||
# before applying. 30 auth requests / 60s / IP.
|
||||
acl is_auth path_beg /api/auth
|
||||
stick-table type ip size 100k expire 60s store http_req_rate(60s)
|
||||
http-request track-sc0 src if is_auth
|
||||
http-request deny deny_status 429 if is_auth { sc_http_req_rate(0) gt 30 }
|
||||
|
||||
# ---- backend: the API container --------------------------------------------
|
||||
# SSE hazards (RESEARCH-v03.md §9): HAProxy 2.x does NOT buffer response
|
||||
# bodies in HTTP mode by default, so text/event-stream streams natively.
|
||||
# The three things that would break it are deliberately absent here:
|
||||
# - NO `option http-buffer-response` on this backend
|
||||
# - NO compression (gzip) on this backend — gzip buffers
|
||||
# text/event-stream; the "classic" silent SSE killer
|
||||
# - keep the sse-starlette ping (15s) untouched
|
||||
backend backend_api
|
||||
option httpchk GET /healthz
|
||||
default-server inter 5s fall 3 rise 2
|
||||
server api1 127.0.0.1:8000 check
|
||||
timeout server 3600s
|
||||
timeout tunnel 3600s
|
||||
|
||||
# ---- backend: the Next.js container ----------------------------------------
|
||||
backend frontend_next
|
||||
timeout server 300s
|
||||
server next1 127.0.0.1:3000 check
|
||||
|
||||
# ---- health contract -------------------------------------------------------
|
||||
# GET /healthz -> 200 {"status":"ok"} (public router, outside /api per
|
||||
# D-103; the only unauthenticated route — T-4.8).
|
||||
# The API container must publish on 127.0.0.1:8000 (loopback only: the
|
||||
# public surface is the TLS front, so the backend is never directly
|
||||
# reachable from outside the host).
|
||||
@@ -1,5 +1,10 @@
|
||||
# .gitea/workflows/ci.yml - ResearchX phase-4 CI gate (researchx#24).
|
||||
#
|
||||
# Ported to the v0.3 line (milestone/v0.3-hosted): phase 4 ships on the
|
||||
# milestone branch, and the pull_request trigger is extended to it so a
|
||||
# phase-4 pull request gets a gate run there. The file itself is the same
|
||||
# gate that ran green on main (researchx#24).
|
||||
#
|
||||
# This file IS the gate. The pre-code clause (coreci/company#105, c14057):
|
||||
# a code phase opens only when the product repo's gating workflow file has
|
||||
# dispatched at least one run on `main` and every job in that file is green
|
||||
@@ -45,7 +50,7 @@ name: ci
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
branches: ["main", "milestone/v0.3-hosted"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -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+.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Build-context hygiene for the backend image (mirrors frontend/.dockerignore).
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
var
|
||||
tests
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
README.md
|
||||
+2
-1
@@ -2,4 +2,5 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.mypy_cache/
|
||||
var/
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# ResearchX backend — production release image (ciagent release system)
|
||||
# Multi-stage: deps -> runtime. FastAPI + uvicorn, single worker, non-root.
|
||||
# Built ON the Docker host from the synced source (no local docker required),
|
||||
# mirroring frontend/Dockerfile: the build context is backend/.
|
||||
#
|
||||
# Contracts this image holds (REQ-83, v0.3 line):
|
||||
# - one uvicorn worker — the run registry lives in process memory
|
||||
# - non-root user "researchx" — house recipe, same as frontend/Dockerfile
|
||||
# - the mutable data root is RESEARCHX_DATA_DIR (default /var/lib/researchx);
|
||||
# that is the variable researchx.config.paths.data_root() reads (D-107),
|
||||
# so the volume mount point is the directory the app actually writes
|
||||
# - /healthz is the public health surface (outside /api, never auth-wrapped,
|
||||
# D-103) — the container healthcheck below uses it
|
||||
# - the serving entrypoint is researchx.api.main (the PRODUCTION wiring:
|
||||
# auth_enabled=True and cookie_secure=True hardwired, the live model
|
||||
# factory reading per-user -> server -> keyless credentials from the
|
||||
# runtime env file). researchx.api.app's module-global is the TEST/DEV
|
||||
# factory — it serves FakeModelAdapter and cwd-based storage, so
|
||||
# hosting it would silently ship fakes (P-3 closure, RESEARCH-v03 §8).
|
||||
# - uid/gid 10001 is fixed so the host-side volume chown is deterministic
|
||||
# (the deploy script reads the ownership from this image)
|
||||
|
||||
# ---- deps ----
|
||||
FROM python:3.12-slim AS deps
|
||||
# uv is the repo's package manager (backend/uv.lock). The repo pins no uv
|
||||
# version, so this install is bounded to a major range; pin it exactly once
|
||||
# the deploy host's uv version is known.
|
||||
RUN pip install --no-cache-dir "uv>=0.8,<2"
|
||||
WORKDIR /app
|
||||
ENV UV_PYTHON_DOWNLOADS=never
|
||||
COPY pyproject.toml uv.lock .python-version ./
|
||||
COPY src ./src
|
||||
# --frozen: use uv.lock as written (no lock rewrite). --no-dev: no test/lint
|
||||
# dependencies in the runtime image. --no-editable: copy the package into
|
||||
# site-packages instead of linking the package source tree.
|
||||
RUN uv sync --frozen --no-dev --no-editable --no-cache --python 3.12
|
||||
|
||||
# ---- runtime ----
|
||||
FROM python:3.12-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
RESEARCHX_DATA_DIR=/var/lib/researchx
|
||||
COPY --from=deps /app/.venv /app/.venv
|
||||
# Non-root app user (house recipe: frontend/Dockerfile runs as researchx).
|
||||
RUN addgroup --gid 10001 researchx \
|
||||
&& adduser --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin researchx \
|
||||
&& mkdir -p /var/lib/researchx \
|
||||
&& chown -R researchx:researchx /var/lib/researchx
|
||||
USER researchx
|
||||
EXPOSE 8000
|
||||
# Public health surface (outside /api, never auth-wrapped). python is already
|
||||
# in the image, so the check needs no extra package.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"]
|
||||
# Single worker (REQ-83): the run registry lives in process memory.
|
||||
CMD ["uvicorn", "researchx.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--proxy-headers"]
|
||||
@@ -9,6 +9,8 @@ dependencies = [
|
||||
"uvicorn>=0.30,<1",
|
||||
"sse-starlette>=2.1,<3",
|
||||
"httpx>=0.27,<1",
|
||||
"argon2-cffi>=23.1,<26",
|
||||
"cryptography>=43",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -5,19 +5,27 @@ import secrets as secrets_module
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.adapters.types import AdapterError
|
||||
from researchx.config.ids import SLUG_RE, generate_slug
|
||||
|
||||
_RUN_ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
_RUN_ID_RE = re.compile(r"^[a-z0-9]{8}$")
|
||||
_ARTIFACT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
|
||||
def generate_run_id() -> str:
|
||||
return "".join(secrets_module.choice(_RUN_ID_ALPHABET) for _ in range(8))
|
||||
"""Generate an 8-char run id (consolidated generator — config/ids.py, P-14)."""
|
||||
return generate_slug()
|
||||
|
||||
|
||||
def _validate_slug(value: str, what: str) -> None:
|
||||
if SLUG_RE.match(value) is None:
|
||||
raise AdapterError(f"invalid {what}")
|
||||
|
||||
|
||||
def _validate_run_id(run_id: str) -> None:
|
||||
if not _RUN_ID_RE.match(run_id):
|
||||
raise AdapterError("invalid run id")
|
||||
_validate_slug(run_id, "run id")
|
||||
|
||||
|
||||
def _validate_owner_id(owner_id: str) -> None:
|
||||
_validate_slug(owner_id, "owner id")
|
||||
|
||||
|
||||
def _validate_artifact_name(name: str) -> None:
|
||||
@@ -26,12 +34,41 @@ def _validate_artifact_name(name: str) -> None:
|
||||
|
||||
|
||||
class LocalStorageAdapter:
|
||||
def __init__(self, base_dir: Path) -> None:
|
||||
"""Owner-scoped local run storage (D-106): runs/<owner_id>/<run_id>/.
|
||||
|
||||
``owner_id`` is REQUIRED (an optional default invites silent
|
||||
unpartitioned use — RESEARCH §5 pitfall) and validated against the
|
||||
shared 8-char slug shape (D-105) so it is a filesystem-safe
|
||||
partition key.
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: Path, owner_id: str) -> None:
|
||||
_validate_owner_id(owner_id)
|
||||
self._base_dir = base_dir
|
||||
self._owner_id = owner_id
|
||||
|
||||
@property
|
||||
def owner_id(self) -> str:
|
||||
return self._owner_id
|
||||
|
||||
@property
|
||||
def base_dir(self) -> Path:
|
||||
return self._base_dir
|
||||
|
||||
def run_dir(self, run_id: str) -> Path:
|
||||
_validate_run_id(run_id)
|
||||
return self._base_dir / "runs" / run_id
|
||||
return self._base_dir / "runs" / self._owner_id / run_id
|
||||
|
||||
def list_run_ids(self) -> list[str]:
|
||||
"""Scan this owner's partition, skipping non-directories/dotfiles."""
|
||||
runs_dir = self._base_dir / "runs" / self._owner_id
|
||||
if not runs_dir.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
child.name
|
||||
for child in runs_dir.iterdir()
|
||||
if child.is_dir() and not child.name.startswith(".")
|
||||
)
|
||||
|
||||
async def write_run_artifact(self, run_id: str, name: str, content: str) -> Path:
|
||||
_validate_artifact_name(name)
|
||||
@@ -58,7 +95,9 @@ class LocalStorageAdapter:
|
||||
try:
|
||||
return target.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
raise AdapterError(f"artifact not found: runs/{run_id}/{name}") from exc
|
||||
raise AdapterError(
|
||||
f"artifact not found: runs/{self._owner_id}/{run_id}/{name}"
|
||||
) from exc
|
||||
|
||||
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path:
|
||||
_validate_artifact_name(name)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Per-run model adapter factory (T-2.5, REQ-74 — D-110, P-2 closure).
|
||||
|
||||
Resolves the per-run credential chain for the CURRENT user:
|
||||
|
||||
user key (decrypted per-user key store) -> tier "user"
|
||||
server env-file OLLAMA_API_KEY -> tier "server"
|
||||
neither (localhost:11434 fallback) -> tier "keyless"
|
||||
|
||||
One adapter instance per ``resolve()`` call — NEVER cached across runs.
|
||||
The per-instance ``_active_base`` fallback mutation in
|
||||
OllamaModelAdapter is then per-run by construction, killing the
|
||||
shared-adapter bug where one user's cloud failure flipped every
|
||||
subsequent user's run to localhost.
|
||||
|
||||
The keyless tier constructs an adapter with no key (no Authorization
|
||||
header is sent — see ollama.py) and the existing cloud→local fallback
|
||||
semantics are preserved: a keyless adapter starts on the cloud base
|
||||
and falls back on failure exactly as before, so localhost:11434
|
||||
remains the zero-cost path.
|
||||
|
||||
Tampered/corrupt key-store envelopes surface as ``KeyStoreError`` (a
|
||||
typed ``AdapterError``) from ``resolve()`` — the caller decides how
|
||||
to present the honest error; the factory NEVER silently falls down the
|
||||
chain on a read failure (that would be a silent tier drop, C3-06).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from researchx.adapters.model import ModelRoles
|
||||
from researchx.adapters.ollama import OllamaModelAdapter
|
||||
from researchx.adapters.secrets import SecretsProvider
|
||||
from researchx.auth.key_store import KEY_SCOPE, KeyStore, KeyStoreError
|
||||
from researchx.auth.models import UserPublic
|
||||
from researchx.auth.settings import DEFAULT_PLANNER_MODEL, DEFAULT_WRITER_MODEL, UserSettings
|
||||
|
||||
TIER_USER = "user"
|
||||
TIER_SERVER = "server"
|
||||
TIER_KEYLESS = "keyless"
|
||||
|
||||
|
||||
class ModelAdapterFactory:
|
||||
"""Resolve (adapter, tier) for a user's run — per call, never cached."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
secrets: SecretsProvider,
|
||||
key_store: KeyStore,
|
||||
*,
|
||||
planner_model: str = DEFAULT_PLANNER_MODEL,
|
||||
writer_model: str = DEFAULT_WRITER_MODEL,
|
||||
cloud_base_url: str | None = None,
|
||||
local_base_url: str | None = None,
|
||||
) -> None:
|
||||
self._secrets = secrets
|
||||
self._key_store = key_store
|
||||
self._planner_model = planner_model
|
||||
self._writer_model = writer_model
|
||||
self._cloud_base_url = cloud_base_url
|
||||
self._local_base_url = local_base_url
|
||||
|
||||
def _construct(
|
||||
self, planner: str, writer: str, api_key: str | None
|
||||
) -> OllamaModelAdapter:
|
||||
return OllamaModelAdapter(
|
||||
self._secrets,
|
||||
planner_model=planner,
|
||||
writer_model=writer,
|
||||
cloud_base_url=self._cloud_base_url,
|
||||
local_base_url=self._local_base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
async def resolve(
|
||||
self, user: UserPublic, settings: UserSettings
|
||||
) -> tuple[OllamaModelAdapter, str]:
|
||||
"""Resolve the user's credential chain and build ONE adapter.
|
||||
|
||||
The adapter's roles honor the user's saved settings
|
||||
(planner_model/writer_model); an unset settings value keeps the
|
||||
factory defaults. Raises ``KeyStoreError`` (an AdapterError)
|
||||
when the user's stored envelope is tampered/corrupt — never a
|
||||
silent tier drop.
|
||||
"""
|
||||
planner = settings.planner_model or self._planner_model
|
||||
writer = settings.writer_model or self._writer_model
|
||||
|
||||
if self._key_store.enabled:
|
||||
# get_key raises KeyStoreError on tamper — honest error, not
|
||||
# a silent fall to server/keyless.
|
||||
user_key = await self._key_store.get_key(user.user_id)
|
||||
if user_key:
|
||||
return self._construct(planner, writer, user_key), TIER_USER
|
||||
|
||||
server_key = self._secrets.resolve(KEY_SCOPE)
|
||||
if server_key:
|
||||
return self._construct(planner, writer, server_key), TIER_SERVER
|
||||
|
||||
return self._construct(planner, writer, None), TIER_KEYLESS
|
||||
|
||||
async def resolve_status(
|
||||
self, user: UserPublic, settings: UserSettings
|
||||
) -> tuple[OllamaModelAdapter, str, str | None]:
|
||||
"""Status variant for GET /api/config: like resolve() but a
|
||||
tampered/corrupt key-store read DEGRADES down the chain and
|
||||
returns the honest error message instead of raising — the
|
||||
config status must never 500 on a corrupt envelope, and the
|
||||
tier must never silently claim 'user' when the key cannot be
|
||||
decrypted. create_run keeps the raising resolve() (a corrupt
|
||||
key there is an honest 409 failure).
|
||||
"""
|
||||
error: str | None = None
|
||||
planner = settings.planner_model or self._planner_model
|
||||
writer = settings.writer_model or self._writer_model
|
||||
|
||||
if self._key_store.enabled:
|
||||
try:
|
||||
user_key = await self._key_store.get_key(user.user_id)
|
||||
except KeyStoreError as exc:
|
||||
# Honest degradation: the tier falls to server/keyless
|
||||
# but the error message is carried alongside — surfaced
|
||||
# by the config status so the user can see WHY.
|
||||
user_key = None
|
||||
error = str(exc)
|
||||
if user_key:
|
||||
return self._construct(planner, writer, user_key), TIER_USER, None
|
||||
|
||||
server_key = self._secrets.resolve(KEY_SCOPE)
|
||||
if server_key:
|
||||
return self._construct(planner, writer, server_key), TIER_SERVER, error
|
||||
|
||||
return self._construct(planner, writer, None), TIER_KEYLESS, error
|
||||
|
||||
async def key_fingerprint(self, user: UserPublic) -> str:
|
||||
"""Stable per-(user, tier, key) probe-cache key component.
|
||||
|
||||
The raw key is NEVER the fingerprint — a short sha256 of the
|
||||
effective key (or the tier itself when keyless) identifies the
|
||||
resolution without exposing material. A key-store read error
|
||||
yields the server/keyless fingerprint; the probe itself will
|
||||
surface the error state through the config status.
|
||||
"""
|
||||
if self._key_store.enabled:
|
||||
try:
|
||||
user_key = await self._key_store.get_key(user.user_id)
|
||||
except KeyStoreError:
|
||||
user_key = None
|
||||
if user_key:
|
||||
return f"{TIER_USER}:{hashlib.sha256(user_key.encode()).hexdigest()[:12]}"
|
||||
server_key = self._secrets.resolve(KEY_SCOPE)
|
||||
if server_key:
|
||||
return f"{TIER_SERVER}:{hashlib.sha256(server_key.encode()).hexdigest()[:12]}"
|
||||
return TIER_KEYLESS
|
||||
|
||||
def roles_defaults(self) -> ModelRoles:
|
||||
return ModelRoles(planner=self._planner_model, writer=self._writer_model)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TIER_KEYLESS",
|
||||
"TIER_SERVER",
|
||||
"TIER_USER",
|
||||
"ModelAdapterFactory",
|
||||
]
|
||||
@@ -3,7 +3,7 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.model import ModelRoles
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.adapters.secrets import SecretsProvider
|
||||
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage, Usage
|
||||
|
||||
_DEFAULT_CLOUD_BASE = "https://ollama.com/v1"
|
||||
@@ -16,18 +16,22 @@ class OllamaModelAdapter:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
secrets: EnvFileSecretsProvider,
|
||||
secrets: SecretsProvider,
|
||||
planner_model: str,
|
||||
writer_model: str,
|
||||
cloud_base_url: str | None = None,
|
||||
local_base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> None:
|
||||
self._secrets = secrets
|
||||
self._roles = ModelRoles(planner=planner_model, writer=writer_model)
|
||||
self._cloud_base = (cloud_base_url or secrets.resolve("OLLAMA_BASE_URL") or _DEFAULT_CLOUD_BASE).rstrip("/")
|
||||
self._local_base = (local_base_url or _DEFAULT_LOCAL_BASE).rstrip("/")
|
||||
self._active_base = self._cloud_base
|
||||
self._api_key = secrets.resolve("OLLAMA_API_KEY")
|
||||
# Explicit api_key takes precedence over the secrets resolve
|
||||
# (T-2.5: the per-run factory passes the user's decrypted key);
|
||||
# the secrets path stays for back-compat (server tier).
|
||||
self._api_key = api_key if api_key is not None else secrets.resolve("OLLAMA_API_KEY")
|
||||
self._last_fallback_reason: str | None = None
|
||||
|
||||
def _headers(self, base_url: str) -> dict[str, str]:
|
||||
@@ -99,12 +103,19 @@ class OllamaModelAdapter:
|
||||
raise AdapterError(f"models listing failed ({type(exc).__name__})") from exc
|
||||
data: dict[str, Any] = response.json()
|
||||
return [str(entry["id"]) for entry in data.get("data", [])]
|
||||
except (AdapterError, httpx.HTTPError):
|
||||
if self._active_base == self._cloud_base:
|
||||
except (AdapterError, httpx.HTTPError) as exc:
|
||||
# Fallback only if there IS a distinct local base to fall
|
||||
# back to (a caller pinning both bases to one endpoint —
|
||||
# e.g. a private gateway — must not recurse forever), and
|
||||
# never leak a raw httpx error: the adapter's outward
|
||||
# contract is a typed AdapterError.
|
||||
if self._active_base == self._cloud_base and self._local_base != self._cloud_base:
|
||||
self._last_fallback_reason = "cloud models listing failed; using local"
|
||||
self._active_base = self._local_base
|
||||
return await self.list_models()
|
||||
raise
|
||||
if isinstance(exc, AdapterError):
|
||||
raise
|
||||
raise AdapterError(f"models listing failed ({type(exc).__name__})") from exc
|
||||
|
||||
def roles(self) -> ModelRoles:
|
||||
return self._roles
|
||||
|
||||
@@ -8,4 +8,6 @@ class StorageAdapter(Protocol):
|
||||
|
||||
async def read_run_artifact(self, run_id: str, name: str) -> str: ...
|
||||
|
||||
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path: ...
|
||||
|
||||
def run_dir(self, run_id: str) -> Path: ...
|
||||
+693
-117
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
@@ -11,11 +13,22 @@ from sse_starlette.sse import EventSourceResponse
|
||||
from researchx.adapters.capability_probe import probe_model_adapter, supports_probe
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.types import AdapterError, ChatResult
|
||||
from researchx.adapters.model_factory import ModelAdapterFactory
|
||||
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
|
||||
from researchx.api.read_models import connections, graph, library, list_runs, suggestions, tasks
|
||||
from researchx.auth.admin_routes import build_admin_router
|
||||
from researchx.auth.guard import Guard, build_admin_guard, build_dev_guard, build_guard
|
||||
from researchx.auth.key_store import KEY_SCOPE, KeyStore, KeyStoreError
|
||||
from researchx.auth.models import _EMAIL_RE, UserPublic
|
||||
from researchx.auth.routes import build_auth_router
|
||||
from researchx.auth.sessions import SessionStore
|
||||
from researchx.auth.settings import SettingsStore, SettingsStoreError, UserSettings
|
||||
from researchx.auth.users import UserError, UserStore
|
||||
from researchx.config.paths import data_root
|
||||
from researchx.engine.actor import RunActor, make_run_config
|
||||
from researchx.engine.events import EventBus, RunEvent
|
||||
from researchx.engine.run_state import Plan, RunState
|
||||
from researchx.engine.states import RunStateName
|
||||
from researchx.engine.states import TERMINAL_STATES, RunStateName
|
||||
from researchx.methodologies import registry
|
||||
|
||||
_CANNED_PLAN = (
|
||||
@@ -27,6 +40,12 @@ _CANNED_WRITER = (
|
||||
"The verified core finding [p-1]. A second corroborated observation [p-2].\n"
|
||||
)
|
||||
|
||||
_CORS_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
|
||||
# Config-status probe cache TTL (P-1): in-process, keyed
|
||||
# (user_id, tier, key_fingerprint) so a key change invalidates.
|
||||
_PROBE_CACHE_TTL_SECONDS = 60.0
|
||||
|
||||
|
||||
class CreateRunBody(BaseModel):
|
||||
question: str = Field(min_length=1, max_length=2000)
|
||||
@@ -39,6 +58,42 @@ class ApproveBody(BaseModel):
|
||||
plan: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SettingsBody(BaseModel):
|
||||
"""PUT /api/config/settings body — partial update (D-111)."""
|
||||
|
||||
planner_model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
writer_model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
methodology_defaults: dict[str, Any] | None = None
|
||||
appearance: str | None = Field(default=None, max_length=40)
|
||||
|
||||
|
||||
class ModelKeyBody(BaseModel):
|
||||
"""PUT /api/config/settings/model-key body — the raw key, accepted,
|
||||
never echoed (REQ-73)."""
|
||||
|
||||
key: str = Field(min_length=1, max_length=512)
|
||||
|
||||
|
||||
class KeyStatus(BaseModel):
|
||||
"""Masked-only key status — the raw key NEVER appears (REQ-73)."""
|
||||
|
||||
configured: bool
|
||||
masked: str | None = None
|
||||
disabled: bool = False
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ConfigStatus(BaseModel):
|
||||
resolved_tier: str # user | server | keyless (REQ-74)
|
||||
key_store: str # enabled | disabled
|
||||
key_status: KeyStatus
|
||||
model_adapter: str
|
||||
search_adapter: str
|
||||
storage_dir: str
|
||||
probe: dict[str, Any] | None = None
|
||||
cost_counters: dict[str, int]
|
||||
|
||||
|
||||
class RunSummary(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
@@ -59,14 +114,6 @@ class MethodologyCard(BaseModel):
|
||||
dials: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigStatus(BaseModel):
|
||||
model_adapter: str
|
||||
search_adapter: str
|
||||
storage_dir: str
|
||||
probe: dict[str, Any] | None = None
|
||||
cost_counters: dict[str, int]
|
||||
|
||||
|
||||
_STATUS_MAP = {
|
||||
RunStateName.PLANNING: "planning",
|
||||
RunStateName.AWAITING_APPROVAL: "awaiting_approval",
|
||||
@@ -84,11 +131,8 @@ _ETA_TABLE = {"rapid-answer": (2, "low"), "deep-report": (12, "medium")}
|
||||
|
||||
|
||||
def _cost_estimate(slug: str) -> str:
|
||||
depth, breadth = 1, 1
|
||||
methodology = registry.get(slug)
|
||||
depth = methodology.dials.depth
|
||||
breadth = methodology.dials.breadth
|
||||
units = depth * breadth
|
||||
units = methodology.dials.depth * methodology.dials.breadth
|
||||
if units <= 1:
|
||||
return "~5k tokens (estimate)"
|
||||
if units <= 4:
|
||||
@@ -104,33 +148,108 @@ def _why_recommended(slug: str) -> str:
|
||||
|
||||
|
||||
class RunRegistry:
|
||||
"""In-memory live-run registry, keyed (owner_id, run_id) (REQ-71).
|
||||
|
||||
Cross-owner access is indistinguishable from a missing run: the
|
||||
keyed lookup misses, routes translate the miss to a 404. Single
|
||||
uvicorn worker only — this state is single-process by design.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._actors: dict[str, tuple[RunActor, asyncio.Task[RunState]]] = {}
|
||||
self._buses: dict[str, EventBus] = {}
|
||||
self.cost_counters: dict[str, int] = {"runs_created": 0, "runs_completed": 0}
|
||||
self._actors: dict[tuple[str, str], tuple[RunActor, asyncio.Task[RunState]]] = {}
|
||||
self._buses: dict[tuple[str, str], EventBus] = {}
|
||||
# Live SSE subscriber refcounts, keyed like _actors (P-6).
|
||||
self._subscribers: dict[tuple[str, str], int] = {}
|
||||
# Owner-keyed cost counters (P-13): per-user usage surface.
|
||||
self.cost_counters: dict[str, dict[str, int]] = {}
|
||||
|
||||
def add(self, run_id: str, actor: RunActor, task: asyncio.Task[RunState], bus: EventBus) -> None:
|
||||
self._actors[run_id] = (actor, task)
|
||||
self._buses[run_id] = bus
|
||||
self.cost_counters["runs_created"] += 1
|
||||
def _counters(self, owner_id: str) -> dict[str, int]:
|
||||
return self.cost_counters.setdefault(owner_id, {"runs_created": 0, "runs_completed": 0})
|
||||
|
||||
def actor(self, run_id: str) -> RunActor:
|
||||
entry = self._actors.get(run_id)
|
||||
def add(
|
||||
self,
|
||||
owner_id: str,
|
||||
run_id: str,
|
||||
actor: RunActor,
|
||||
task: asyncio.Task[RunState],
|
||||
bus: EventBus,
|
||||
) -> None:
|
||||
self._actors[(owner_id, run_id)] = (actor, task)
|
||||
self._buses[(owner_id, run_id)] = bus
|
||||
self._counters(owner_id)["runs_created"] += 1
|
||||
|
||||
def actor(self, owner_id: str, run_id: str) -> RunActor:
|
||||
entry = self._actors.get((owner_id, run_id))
|
||||
if entry is None:
|
||||
raise KeyError(run_id)
|
||||
raise KeyError((owner_id, run_id))
|
||||
return entry[0]
|
||||
|
||||
def bus(self, run_id: str) -> EventBus:
|
||||
bus = self._buses.get(run_id)
|
||||
def bus(self, owner_id: str, run_id: str) -> EventBus:
|
||||
bus = self._buses.get((owner_id, run_id))
|
||||
if bus is None:
|
||||
raise KeyError(run_id)
|
||||
raise KeyError((owner_id, run_id))
|
||||
return bus
|
||||
|
||||
def has(self, run_id: str) -> bool:
|
||||
return run_id in self._actors
|
||||
def has(self, owner_id: str, run_id: str) -> bool:
|
||||
return (owner_id, run_id) in self._actors
|
||||
|
||||
def note_completed(self) -> None:
|
||||
self.cost_counters["runs_completed"] += 1
|
||||
def note_completed(self, owner_id: str) -> None:
|
||||
self._counters(owner_id)["runs_completed"] += 1
|
||||
|
||||
def completed_snapshot(self, owner_id: str) -> dict[str, int]:
|
||||
"""Current counters for *owner_id*, derived where possible.
|
||||
|
||||
``runs_completed`` is computed from the live registry (a run is
|
||||
completed iff its actor sits in a terminal state) and merged
|
||||
with the sticky counter — which keeps counting runs that have
|
||||
since been pruned from the registry. Deriving from actor state
|
||||
removes the scheduling race between the actor finishing and a
|
||||
detached watcher task incrementing the counter.
|
||||
"""
|
||||
counters = dict(self._counters(owner_id))
|
||||
live_done = sum(
|
||||
1
|
||||
for (owner, _), (actor, _) in self._actors.items()
|
||||
if owner == owner_id and actor.state.state == RunStateName.DONE
|
||||
)
|
||||
if live_done:
|
||||
# Prefer live truth when registry entries still exist; the
|
||||
# sticky counter catches pruned/evicted runs afterwards.
|
||||
counters["runs_completed"] = max(counters["runs_completed"], live_done)
|
||||
return counters
|
||||
|
||||
def subscribe_events(self, owner_id: str, run_id: str) -> None:
|
||||
self._subscribers[(owner_id, run_id)] = self._subscribers.get((owner_id, run_id), 0) + 1
|
||||
|
||||
def unsubscribe_events(self, owner_id: str, run_id: str) -> None:
|
||||
count = self._subscribers.get((owner_id, run_id), 0)
|
||||
if count <= 1:
|
||||
self._subscribers.pop((owner_id, run_id), None)
|
||||
else:
|
||||
self._subscribers[(owner_id, run_id)] = count - 1
|
||||
|
||||
def subscriber_count(self, owner_id: str, run_id: str) -> int:
|
||||
return self._subscribers.get((owner_id, run_id), 0)
|
||||
|
||||
def prune(self, owner_id: str, run_id: str) -> bool:
|
||||
"""Drop a terminal entry with zero live SSE subscribers (P-6).
|
||||
|
||||
Terminal entries are otherwise a slow multi-user leak. Events
|
||||
for a pruned run 404 afterwards — documented acceptable; the
|
||||
trace persists on disk.
|
||||
"""
|
||||
entry = self._actors.get((owner_id, run_id))
|
||||
if entry is None:
|
||||
return False
|
||||
actor = entry[0]
|
||||
if actor.state.state not in TERMINAL_STATES:
|
||||
return False
|
||||
if self.subscriber_count(owner_id, run_id) > 0:
|
||||
return False
|
||||
self._actors.pop((owner_id, run_id), None)
|
||||
self._buses.pop((owner_id, run_id), None)
|
||||
self._subscribers.pop((owner_id, run_id), None)
|
||||
return True
|
||||
|
||||
|
||||
def create_app(
|
||||
@@ -138,27 +257,81 @@ def create_app(
|
||||
storage_dir: Path | None = None,
|
||||
model_adapter: Any = None,
|
||||
search_adapter: Any = None,
|
||||
auth_enabled: bool = True,
|
||||
cookie_secure: bool = False,
|
||||
data_dir: Path | None = None,
|
||||
key_store: KeyStore | None = None,
|
||||
settings_store: SettingsStore | None = None,
|
||||
model_factory: ModelAdapterFactory | None = None,
|
||||
secrets_provider: Any = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="ResearchX API", version="0.2.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
"""Build the ResearchX API app.
|
||||
|
||||
storage = LocalStorageAdapter(base_dir=storage_dir or Path.cwd() / ".researchx")
|
||||
``auth_enabled=False`` is a dev/test harness parameter ONLY — never
|
||||
an env var readable in production (C3-16). It resolves a synthetic
|
||||
dev admin for every request so zero-key suites keep working.
|
||||
``data_dir`` overrides the auth-store location (defaults to
|
||||
``data_root()``); ``storage_dir`` remains the run-artifact base.
|
||||
|
||||
Per-user stores (T-2.2/T-2.3): ``key_store`` defaults to a KeyStore
|
||||
over the data root (disabled when no KEK is configured — honest
|
||||
degradation); ``settings_store`` likewise. Both are injectable for
|
||||
tests. ``model_factory`` (T-2.5) resolves the per-user credential
|
||||
chain user → server → keyless for ``create_run`` and
|
||||
``GET /api/config``; when absent the fixed ``model_adapter`` is
|
||||
used (test harness), tiered resolution disabled by construction.
|
||||
"""
|
||||
app = FastAPI(title="ResearchX API", version="0.2.0")
|
||||
|
||||
# D-107: run artifacts live under the data root (storage_dir
|
||||
# overrides for tests); auth stores always share the same root.
|
||||
storage_base = storage_dir if storage_dir is not None else data_root()
|
||||
runs = RunRegistry()
|
||||
model = model_adapter or FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_CANNED_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_CANNED_WRITER, model="fake-writer"),
|
||||
]
|
||||
)
|
||||
# The fallback model is constructed ONLY when no factory is wired:
|
||||
# the factory path (production, T-2.5) resolves a real adapter per
|
||||
# run and the production entrypoint can therefore never hold a
|
||||
# FakeModelAdapter in memory (P-3).
|
||||
model: Any
|
||||
if model_adapter is not None:
|
||||
model = model_adapter
|
||||
elif model_factory is not None:
|
||||
model = None # every consumer goes through the factory
|
||||
else:
|
||||
model = FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_CANNED_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_CANNED_WRITER, model="fake-writer"),
|
||||
]
|
||||
)
|
||||
search = search_adapter or FakeSearchAdapter(CORPUS)
|
||||
|
||||
def _summary(run_id: str) -> RunSummary:
|
||||
actor = runs.actor(run_id)
|
||||
auth_root = data_dir if data_dir is not None else data_root()
|
||||
users = UserStore(auth_root / "users.json")
|
||||
sessions = SessionStore(auth_root / "sessions.json")
|
||||
|
||||
# Per-user stores wired from the same root as the auth stores;
|
||||
# injectable for tests (key_store=None -> disabled, honest reason).
|
||||
key_store = key_store if key_store is not None else KeyStore(
|
||||
secrets_provider or _NullSecrets(), auth_root
|
||||
)
|
||||
settings_store = settings_store if settings_store is not None else SettingsStore(auth_root)
|
||||
|
||||
guard: Guard = build_guard(users, sessions) if auth_enabled else build_dev_guard()
|
||||
admin_guard = build_admin_guard(guard)
|
||||
|
||||
# In-process probe cache keyed (user_id, tier, key_fingerprint)
|
||||
# with a short TTL (P-1 closure — the probe runs against the
|
||||
# RESOLVED tier, never stolen from the registry, and a key change
|
||||
# rotates the fingerprint which invalidates the entry).
|
||||
probe_cache: dict[tuple[str, str, str], tuple[float, dict[str, Any] | None]] = {}
|
||||
|
||||
def _owner_storage(user: UserPublic) -> LocalStorageAdapter:
|
||||
"""Owner-scoped storage, constructed per request from the auth
|
||||
dependency (REQ-71): runs/<user_id>/<run_id>/."""
|
||||
return LocalStorageAdapter(base_dir=storage_base, owner_id=user.user_id)
|
||||
|
||||
def _summary(user: UserPublic, run_id: str) -> RunSummary:
|
||||
actor = runs.actor(user.user_id, run_id)
|
||||
state = actor.state
|
||||
return RunSummary(
|
||||
id=run_id,
|
||||
@@ -169,21 +342,142 @@ def create_app(
|
||||
started_at=state.created_at.isoformat(),
|
||||
)
|
||||
|
||||
@app.get("/api/config")
|
||||
async def config_status() -> ConfigStatus:
|
||||
latest_probe = None
|
||||
for run_id in list(runs._actors)[-1:]:
|
||||
actor_probe = runs.actor(run_id).state.probe
|
||||
latest_probe = actor_probe
|
||||
return ConfigStatus(
|
||||
model_adapter=type(model).__name__,
|
||||
search_adapter=type(search).__name__,
|
||||
storage_dir=str(storage.run_dir("00000000").parent),
|
||||
probe=latest_probe,
|
||||
cost_counters=runs.cost_counters,
|
||||
async def _masked_key_status(user: UserPublic) -> KeyStatus:
|
||||
"""Masked-only key status — never the raw key (REQ-73)."""
|
||||
try:
|
||||
status = await key_store.masked_status(user.user_id)
|
||||
except KeyStoreError as exc:
|
||||
# Stored-but-unreadable: surface the error state honestly.
|
||||
return KeyStatus(
|
||||
configured=False, masked=None, disabled=not key_store.enabled, reason=str(exc)
|
||||
)
|
||||
masked = status.get("masked")
|
||||
reason = status.get("reason")
|
||||
return KeyStatus(
|
||||
configured=bool(status.get("configured")),
|
||||
masked=str(masked) if masked is not None else None,
|
||||
disabled=bool(status.get("disabled")),
|
||||
reason=str(reason) if reason is not None else None,
|
||||
)
|
||||
|
||||
@app.get("/api/methodologies")
|
||||
# ------------------------------------------------------------------
|
||||
# PUBLIC surface: /healthz outside /api (D-103 — plain GET for the
|
||||
# TLS-front proxy, never auth-wrapped) + /api/auth/* (no
|
||||
# require_user; the auth router enforces the CSRF header on its
|
||||
# own POSTs).
|
||||
# ------------------------------------------------------------------
|
||||
public = build_auth_router(users, sessions, cookie_secure=cookie_secure)
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PROTECTED router: every /api business route carries the guard.
|
||||
# Admin user management additionally carries the admin guard.
|
||||
# ------------------------------------------------------------------
|
||||
protected = APIRouter(dependencies=[Depends(guard)])
|
||||
|
||||
protected.include_router(build_admin_router(users, sessions, admin_guard))
|
||||
|
||||
@protected.get("/api/config", response_model=ConfigStatus)
|
||||
async def config_status(user: Annotated[UserPublic, Depends(guard)]) -> ConfigStatus:
|
||||
"""Current user's resolved credential tier + status (T-2.6).
|
||||
|
||||
P-1 closure: the probe is resolved via the factory for the
|
||||
CURRENT user — no run creation, no reads of the private
|
||||
registry dict, correct with zero runs, no cross-user leak.
|
||||
A corrupt/tampered stored envelope degrades the tier honestly
|
||||
(server/keyless) and surfaces the error in key_status.reason —
|
||||
never a 500, never a silent drop.
|
||||
"""
|
||||
tier = "keyless"
|
||||
adapter_name = type(model).__name__ if model is not None else "unknown"
|
||||
probe: dict[str, Any] | None = None
|
||||
resolve_error: str | None = None
|
||||
|
||||
if model_factory is not None:
|
||||
settings = await settings_store.get(user.user_id)
|
||||
adapter, tier, resolve_error = await model_factory.resolve_status(user, settings)
|
||||
adapter_name = type(adapter).__name__
|
||||
fingerprint = await model_factory.key_fingerprint(user)
|
||||
cache_key = (user.user_id, tier, fingerprint)
|
||||
now = time.monotonic()
|
||||
cached = probe_cache.get(cache_key)
|
||||
if cached is not None and now - cached[0] < _PROBE_CACHE_TTL_SECONDS:
|
||||
probe = cached[1]
|
||||
elif supports_probe(adapter):
|
||||
try:
|
||||
probe_result = await probe_model_adapter(adapter)
|
||||
probe = probe_result.model_dump(mode="json")
|
||||
probe_cache[cache_key] = (now, probe)
|
||||
except AdapterError:
|
||||
probe = None
|
||||
probe_cache[cache_key] = (now, None)
|
||||
key_status = await _masked_key_status(user)
|
||||
if resolve_error is not None and key_status.reason is None:
|
||||
# The tier degraded honestly; say WHY (never silently).
|
||||
key_status = key_status.model_copy(update={"reason": resolve_error})
|
||||
return ConfigStatus(
|
||||
resolved_tier=tier,
|
||||
key_store="enabled" if key_store.enabled else "disabled",
|
||||
key_status=key_status,
|
||||
model_adapter=adapter_name,
|
||||
search_adapter=type(search).__name__,
|
||||
storage_dir=str(storage_base),
|
||||
probe=probe,
|
||||
cost_counters=runs.completed_snapshot(user.user_id),
|
||||
)
|
||||
|
||||
@protected.get("/api/config/settings", response_model=UserSettings)
|
||||
async def get_settings(user: Annotated[UserPublic, Depends(guard)]) -> UserSettings:
|
||||
return await settings_store.get(user.user_id)
|
||||
|
||||
@protected.put("/api/config/settings", response_model=UserSettings)
|
||||
async def put_settings(
|
||||
body: SettingsBody, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> UserSettings:
|
||||
"""Persist a partial settings update (REQ-72); returns the
|
||||
updated settings. An explicit null field is treated as absent
|
||||
(partial-merge semantics — the store refuses null-valued
|
||||
fields, so a null can never poison the on-disk payload)."""
|
||||
updates: dict[str, Any] = {
|
||||
key: value
|
||||
for key, value in body.model_dump(exclude_unset=True).items()
|
||||
if value is not None
|
||||
}
|
||||
try:
|
||||
current = await settings_store.get(user.user_id)
|
||||
merged = current.model_copy(update=updates)
|
||||
return await settings_store.update(user.user_id, merged)
|
||||
except SettingsStoreError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
@protected.put("/api/config/settings/model-key", response_model=KeyStatus)
|
||||
async def put_model_key(
|
||||
body: ModelKeyBody, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> KeyStatus:
|
||||
"""Accept the user's raw model key, encrypt it at rest, return
|
||||
ONLY the masked status (REQ-73 — the raw key never appears in
|
||||
any response, log, or trace). Without a KEK: honest 409."""
|
||||
if not key_store.enabled:
|
||||
# Honest refusal, never a silent no-op.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"key_store": "disabled",
|
||||
"reason": key_store.disabled_reason,
|
||||
},
|
||||
)
|
||||
await key_store.set_key(user.user_id, KEY_SCOPE, body.key)
|
||||
return await _masked_key_status(user)
|
||||
|
||||
@protected.get("/api/config/settings/model-key", response_model=KeyStatus)
|
||||
async def get_model_key_status(user: Annotated[UserPublic, Depends(guard)]) -> KeyStatus:
|
||||
"""Masked-only key status (never the raw key)."""
|
||||
return await _masked_key_status(user)
|
||||
|
||||
@protected.get("/api/methodologies")
|
||||
async def methodologies() -> list[MethodologyCard]:
|
||||
cards: list[MethodologyCard] = []
|
||||
for methodology in registry.list():
|
||||
@@ -202,10 +496,29 @@ def create_app(
|
||||
)
|
||||
return cards
|
||||
|
||||
@app.post("/api/runs", status_code=201)
|
||||
async def create_run(body: CreateRunBody) -> RunSummary:
|
||||
@protected.post("/api/runs", status_code=201)
|
||||
async def create_run(body: CreateRunBody, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if body.methodology not in registry:
|
||||
raise HTTPException(status_code=404, detail="unknown methodology")
|
||||
storage = _owner_storage(user)
|
||||
|
||||
# Per-run credential resolution (T-2.5, REQ-74): one adapter
|
||||
# instance per run — the _active_base fallback mutation is
|
||||
# per-instance by construction (P-2 closure).
|
||||
tier: str | None = None
|
||||
if model_factory is not None:
|
||||
settings = await settings_store.get(user.user_id)
|
||||
try:
|
||||
run_model, tier = await model_factory.resolve(user, settings)
|
||||
except KeyStoreError as exc:
|
||||
# A tampered/corrupt stored key is an honest failure —
|
||||
# never a silent tier drop and never a wrong-key run.
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
else:
|
||||
# No factory: the fixed test-harness adapter (create_app
|
||||
# guarantees one exists whenever the factory is absent).
|
||||
run_model = model
|
||||
|
||||
try:
|
||||
run_id, config = make_run_config(
|
||||
question=body.question,
|
||||
@@ -213,6 +526,7 @@ def create_app(
|
||||
methodology_slug=body.methodology,
|
||||
overrides=body.dial_overrides,
|
||||
registry=registry,
|
||||
owner_id=user.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
@@ -221,42 +535,49 @@ def create_app(
|
||||
bus = EventBus()
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=model,
|
||||
model=run_model,
|
||||
search=search,
|
||||
storage=storage,
|
||||
registry=registry,
|
||||
event_bus=bus,
|
||||
on_complete=runs.note_completed,
|
||||
)
|
||||
probe_data: dict[str, Any] | None = None
|
||||
if supports_probe(model):
|
||||
# Stamp the owner on the persisted state (REQ-71). The actor
|
||||
# itself is untouched — engine contracts unchanged.
|
||||
actor.state.owner_id = user.user_id
|
||||
actor.state.credential_tier = tier # REQ-74: auditability
|
||||
if supports_probe(run_model):
|
||||
try:
|
||||
probe = await probe_model_adapter(model)
|
||||
probe_data = probe.model_dump(mode="json")
|
||||
await actor.attach_probe(probe_data)
|
||||
probe = await probe_model_adapter(run_model)
|
||||
await actor.attach_probe(probe.model_dump(mode="json"))
|
||||
except AdapterError:
|
||||
probe_data = None
|
||||
pass
|
||||
|
||||
task = asyncio.create_task(actor.run())
|
||||
runs.add(run_id, actor, task, bus)
|
||||
runs.add(user.user_id, run_id, actor, task, bus)
|
||||
|
||||
async def _watch() -> None:
|
||||
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 +585,40 @@ def create_app(
|
||||
except ValidationError:
|
||||
raise HTTPException(status_code=422, detail="invalid plan")
|
||||
await actor.approve(edited_plan)
|
||||
return _summary(run_id)
|
||||
return _summary(user, run_id)
|
||||
|
||||
@app.post("/api/runs/{run_id}/pause")
|
||||
async def pause_run(run_id: str) -> RunSummary:
|
||||
if not runs.has(run_id):
|
||||
@protected.post("/api/runs/{run_id}/pause")
|
||||
async def pause_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
runs.actor(run_id).pause()
|
||||
return _summary(run_id)
|
||||
runs.actor(user.user_id, run_id).pause()
|
||||
return _summary(user, run_id)
|
||||
|
||||
@app.post("/api/runs/{run_id}/resume")
|
||||
async def resume_run(run_id: str) -> RunSummary:
|
||||
if not runs.has(run_id):
|
||||
@protected.post("/api/runs/{run_id}/resume")
|
||||
async def resume_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
runs.actor(run_id).resume()
|
||||
return _summary(run_id)
|
||||
runs.actor(user.user_id, run_id).resume()
|
||||
return _summary(user, run_id)
|
||||
|
||||
@app.post("/api/runs/{run_id}/cancel")
|
||||
async def cancel_run(run_id: str) -> RunSummary:
|
||||
if not runs.has(run_id):
|
||||
@protected.post("/api/runs/{run_id}/cancel")
|
||||
async def cancel_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
actor = runs.actor(run_id)
|
||||
actor = runs.actor(user.user_id, run_id)
|
||||
actor.cancel()
|
||||
actor.resume()
|
||||
return _summary(run_id)
|
||||
return _summary(user, run_id)
|
||||
|
||||
@app.get("/api/runs/{run_id}/events")
|
||||
async def run_events(run_id: str) -> EventSourceResponse:
|
||||
if not runs.has(run_id):
|
||||
@protected.get("/api/runs/{run_id}/events")
|
||||
async def run_events(
|
||||
run_id: str, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> EventSourceResponse:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
bus = runs.bus(run_id)
|
||||
actor = runs.actor(run_id)
|
||||
terminal = {RunStateName.DONE, RunStateName.CANCELLED, RunStateName.FAILED}
|
||||
bus = runs.bus(user.user_id, run_id)
|
||||
actor = runs.actor(user.user_id, run_id)
|
||||
terminal = TERMINAL_STATES
|
||||
|
||||
async def event_stream() -> AsyncIterator[dict[str, str]]:
|
||||
queue: asyncio.Queue[RunEvent] = asyncio.Queue()
|
||||
@@ -303,6 +626,7 @@ def create_app(
|
||||
async def forward(event: RunEvent) -> None:
|
||||
await queue.put(event)
|
||||
|
||||
runs.subscribe_events(user.user_id, run_id) # SSE refcount (P-6)
|
||||
bus.subscribe(forward)
|
||||
try:
|
||||
replay = bus.replay()
|
||||
@@ -324,35 +648,287 @@ def create_app(
|
||||
return
|
||||
finally:
|
||||
bus.unsubscribe(forward)
|
||||
runs.unsubscribe_events(user.user_id, run_id)
|
||||
# Registry pruning (P-6): a terminal run whose last SSE
|
||||
# subscriber just left is dropped from the registry.
|
||||
# Events for a pruned run 404 afterwards — documented
|
||||
# acceptable; the trace persists on disk.
|
||||
runs.prune(user.user_id, run_id)
|
||||
|
||||
return EventSourceResponse(event_stream())
|
||||
|
||||
return EventSourceResponse(event_stream())
|
||||
|
||||
@app.get("/api/runs/{run_id}/report")
|
||||
async def run_report(run_id: str) -> dict[str, Any]:
|
||||
if not runs.has(run_id):
|
||||
@protected.get("/api/runs/{run_id}/report")
|
||||
async def run_report(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
|
||||
"""Disk-read, registry-independent (P-8 full closure, T-3.3):
|
||||
the run directory under THIS owner is the existence check — no
|
||||
RunRegistry involvement, correct after a restart with zero
|
||||
in-memory actors. Pre-artifact DONE runs (and any run whose
|
||||
report.json was not yet written) fall back to markdown-only
|
||||
with null report/evidence — never a 500 (D-106: old artifacts
|
||||
disposable)."""
|
||||
storage = _owner_storage(user)
|
||||
if not (storage.run_dir(run_id)).is_dir():
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
try:
|
||||
content = await storage.read_run_artifact(run_id, "report.md")
|
||||
markdown = await storage.read_run_artifact(run_id, "report.md")
|
||||
except AdapterError:
|
||||
raise HTTPException(status_code=404, detail="report not ready")
|
||||
return {"run_id": run_id, "markdown": content}
|
||||
report: dict[str, Any] | None = None
|
||||
evidence: dict[str, Any] | None = None
|
||||
try:
|
||||
parsed = json.loads(await storage.read_run_artifact(run_id, "report.json"))
|
||||
if isinstance(parsed, dict):
|
||||
report = parsed
|
||||
except (AdapterError, json.JSONDecodeError):
|
||||
pass
|
||||
try:
|
||||
parsed = json.loads(await storage.read_run_artifact(run_id, "evidence.json"))
|
||||
if isinstance(parsed, dict):
|
||||
evidence = parsed
|
||||
except (AdapterError, json.JSONDecodeError):
|
||||
pass
|
||||
return {"run_id": run_id, "markdown": markdown, "report": report, "evidence": evidence}
|
||||
|
||||
@app.get("/api/runs/{run_id}/ledger")
|
||||
async def run_ledger(run_id: str) -> dict[str, Any]:
|
||||
if not runs.has(run_id):
|
||||
@protected.get("/api/runs/{run_id}/ledger")
|
||||
async def run_ledger(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
|
||||
"""Disk-read, registry-independent (T-3.3): same owner-dir
|
||||
existence check as the report route."""
|
||||
storage = _owner_storage(user)
|
||||
if not (storage.run_dir(run_id)).is_dir():
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
try:
|
||||
content = await storage.read_run_artifact(run_id, "ledger.json")
|
||||
except AdapterError:
|
||||
raise HTTPException(status_code=404, detail="ledger not ready")
|
||||
import json
|
||||
|
||||
data: dict[str, Any] = json.loads(content)
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------- T-3.2/T-3.3
|
||||
# Live surfaces (REQ-76..79): read-model routes — disk reads only,
|
||||
# owner-scoped via the guard, NEVER the in-memory RunRegistry
|
||||
# (D-115). Every route below is correct after a restart with zero
|
||||
# in-process actors (P-8 full closure, pinned by test).
|
||||
|
||||
@protected.get("/api/runs")
|
||||
async def list_runs_route(user: Annotated[UserPublic, Depends(guard)]) -> list[RunSummary]:
|
||||
"""All of this owner's runs from disk state files, most recent
|
||||
first — the registry-independent run list (post-sweep
|
||||
CANCELLED runs surface cancelled, NOT open)."""
|
||||
records = list_runs(storage_base, user.user_id)
|
||||
summaries: list[RunSummary] = []
|
||||
for record in records:
|
||||
plan = record.state.get("plan")
|
||||
summaries.append(
|
||||
RunSummary(
|
||||
id=record.run_id,
|
||||
status=_STATUS_MAP.get(RunStateName(record.status), "failed"),
|
||||
question=record.question,
|
||||
methodology=record.methodology_slug,
|
||||
plan=plan if isinstance(plan, dict) else None,
|
||||
started_at=record.created_at,
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
@protected.get("/api/library")
|
||||
async def library_route(
|
||||
user: Annotated[UserPublic, Depends(guard)], sort: str = "recent"
|
||||
) -> dict[str, Any]:
|
||||
return library(storage_base, user.user_id, sort=sort)
|
||||
|
||||
@protected.get("/api/library/connections")
|
||||
async def connections_route(user: Annotated[UserPublic, Depends(guard)]) -> list[dict[str, Any]]:
|
||||
return connections(storage_base, user.user_id)
|
||||
|
||||
@protected.get("/api/graph")
|
||||
async def graph_route(user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
|
||||
return graph(storage_base, user.user_id)
|
||||
|
||||
@protected.get("/api/tasks")
|
||||
async def tasks_route(user: Annotated[UserPublic, Depends(guard)]) -> list[dict[str, str]]:
|
||||
return tasks(storage_base, user.user_id)
|
||||
|
||||
@protected.get("/api/suggestions")
|
||||
async def suggestions_route(
|
||||
user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> list[dict[str, str]]:
|
||||
return suggestions(storage_base, user.user_id)
|
||||
|
||||
# ------------------------------------------------------------- T-3.6
|
||||
# Refine + profile (REQ-80/81). The refine planner calls ride the
|
||||
# SAME per-user credential chain as runs (P2 factory); an
|
||||
# unavailable model is an honest 503, never a hang.
|
||||
|
||||
class RefineDraftBody(BaseModel):
|
||||
draft: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
class RefineComposeBody(BaseModel):
|
||||
draft: str = Field(min_length=1, max_length=2000)
|
||||
answers: list[str] = Field(default_factory=list, max_length=10)
|
||||
|
||||
_REFINE_QUESTIONS_PROMPT = (
|
||||
"You are a research question refiner. The user drafted a research "
|
||||
"question. Reply with a JSON object {{\"questions\": [...]}} holding "
|
||||
"exactly three short clarifying questions that would sharpen the "
|
||||
"draft. No commentary, JSON only.\nDraft: {draft}"
|
||||
)
|
||||
_REFINE_COMPOSE_PROMPT = (
|
||||
"You are a research question refiner. The user drafted a research "
|
||||
"question and answered clarifying questions. Compose the refined "
|
||||
"research question and a short suggested-context paragraph. Reply "
|
||||
"with a JSON object {{\"question\": \"...\", \"context\": \"...\"}}, "
|
||||
"JSON only.\nDraft: {draft}\nAnswers: {answers}"
|
||||
)
|
||||
|
||||
def _parse_refine_json(content: str) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AdapterError("refine planner returned non-JSON") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise AdapterError("refine planner returned non-JSON")
|
||||
return parsed
|
||||
|
||||
async def _refine_model(user: UserPublic) -> tuple[Any, str]:
|
||||
"""Resolve the per-user adapter for a refine call — the same
|
||||
chain as create_run (user -> server -> keyless), one fresh
|
||||
adapter instance per call (P-2)."""
|
||||
if model_factory is not None:
|
||||
settings = await settings_store.get(user.user_id)
|
||||
run_model, tier = await model_factory.resolve(user, settings)
|
||||
return run_model, tier
|
||||
if model is None: # factory wired but absent — impossible via create_app
|
||||
raise AdapterError("no model adapter available")
|
||||
return model, "keyless"
|
||||
|
||||
@protected.post("/api/refine/questions")
|
||||
async def refine_questions(
|
||||
body: RefineDraftBody, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> dict[str, Any]:
|
||||
draft = body.draft.strip()
|
||||
if not draft:
|
||||
raise HTTPException(status_code=422, detail="draft must not be empty")
|
||||
try:
|
||||
adapter, _tier = await _refine_model(user)
|
||||
result = await adapter.chat(
|
||||
[ModelMessage(role="user", content=_REFINE_QUESTIONS_PROMPT.format(draft=draft))],
|
||||
model=adapter.roles().planner,
|
||||
json_mode=True,
|
||||
)
|
||||
except AdapterError as exc:
|
||||
# Honest 503 (T-3.6): the resolved tier is unavailable —
|
||||
# never a hang, never a silent success.
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
parsed = _parse_refine_json(result.content)
|
||||
raw = parsed.get("questions", [])
|
||||
questions = [str(q).strip() for q in raw if str(q).strip()] if isinstance(raw, list) else []
|
||||
if not questions:
|
||||
raise HTTPException(status_code=503, detail="refine planner returned no questions")
|
||||
return {"questions": questions[:3]}
|
||||
|
||||
@protected.post("/api/refine/compose")
|
||||
async def refine_compose(
|
||||
body: RefineComposeBody, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> dict[str, str]:
|
||||
draft = body.draft.strip()
|
||||
if not draft:
|
||||
raise HTTPException(status_code=422, detail="draft must not be empty")
|
||||
answers = [str(a).strip() for a in body.answers if str(a).strip()]
|
||||
try:
|
||||
adapter, _tier = await _refine_model(user)
|
||||
result = await adapter.chat(
|
||||
[
|
||||
ModelMessage(
|
||||
role="user",
|
||||
content=_REFINE_COMPOSE_PROMPT.format(
|
||||
draft=draft, answers=json.dumps(answers)
|
||||
),
|
||||
)
|
||||
],
|
||||
model=adapter.roles().planner,
|
||||
json_mode=True,
|
||||
)
|
||||
except AdapterError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
parsed = _parse_refine_json(result.content)
|
||||
question = str(parsed.get("question", "")).strip()
|
||||
if not question:
|
||||
raise HTTPException(status_code=503, detail="refine planner returned no question")
|
||||
return {"question": question, "context": str(parsed.get("context", "")).strip()}
|
||||
|
||||
# ------------------------------------------------------------- profile
|
||||
|
||||
class ProfileBody(BaseModel):
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
email: str | None = Field(default=None, pattern=_EMAIL_RE, max_length=254)
|
||||
|
||||
@protected.get("/api/profile")
|
||||
async def get_profile(user: Annotated[UserPublic, Depends(guard)]) -> dict[str, str]:
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"display_name": user.display_name,
|
||||
"email": user.email,
|
||||
}
|
||||
|
||||
@protected.put("/api/profile")
|
||||
async def put_profile(
|
||||
body: ProfileBody, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> dict[str, str]:
|
||||
"""Update display name and/or email (no deletion, REQ-81).
|
||||
The email stays unique (casefolded) across other users."""
|
||||
stored = users.get(user.user_id)
|
||||
if stored is None:
|
||||
raise HTTPException(status_code=401, detail="account disabled")
|
||||
try:
|
||||
updated = await users.update_profile(
|
||||
user.user_id,
|
||||
display_name=body.display_name,
|
||||
email=body.email,
|
||||
)
|
||||
except UserError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {
|
||||
"user_id": updated.user_id,
|
||||
"display_name": updated.display_name,
|
||||
"email": updated.email,
|
||||
}
|
||||
|
||||
app.include_router(public)
|
||||
app.include_router(protected)
|
||||
|
||||
# Startup sweep (D-115, G-101): every non-terminal state.json under
|
||||
# the storage base is rewritten as CANCELLED — in-flight actors died
|
||||
# with the previous process; rehydration is out of v0.3 scope. Runs
|
||||
# synchronously at app construction so the very first request
|
||||
# already sees a consistent disk.
|
||||
from researchx.api.sweep import sweep_in_flight_runs
|
||||
|
||||
sweep_in_flight_runs(storage_base)
|
||||
|
||||
# CORS is added AFTER the routers so it is the OUTERMOST middleware
|
||||
# (starlette builds the stack in add-order: last added = outermost)
|
||||
# — preflight OPTIONS is answered without any auth 401 (P-4/C3-04).
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class _NullSecrets:
|
||||
"""No-secrets provider: the default key store is disabled unless a
|
||||
secrets provider is injected (zero-key test harness stays green)."""
|
||||
|
||||
def resolve(self, name: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# Dev-only module app (README's `uvicorn researchx.api.app:app`).
|
||||
# Pitfall P-12/§8 item 3: this serves FAKES with default storage —
|
||||
# the production entrypoint (researchx.api.main, Phase 2) must never
|
||||
# import this global. Single worker only (in-memory stores by design).
|
||||
app = create_app()
|
||||
@@ -64,10 +64,16 @@ class EndlessModelAdapter:
|
||||
|
||||
def main() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# Dev/E2E bridge: auth explicitly OFF (P-5) — a loopback-only,
|
||||
# disposable harness. Never the production entrypoint; the
|
||||
# production server (researchx.api.main, Phase 2) never passes
|
||||
# auth_enabled.
|
||||
app = create_app(
|
||||
storage_dir=Path(tmp),
|
||||
model_adapter=EndlessModelAdapter(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=Path(tmp),
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Production entrypoint (T-2.7 — P-3 backend closure).
|
||||
|
||||
``uvicorn researchx.api.main:app`` — the ONLY serving entry the
|
||||
container ever runs (T-4.1). Distinct from ``researchx.api.app`` (the
|
||||
test/dev factory, whose module-global serves fakes):
|
||||
|
||||
- ``auth_enabled=True`` is HARDWIRED — not a parameter here, never an
|
||||
env var, never False on any path that serves traffic (REQ-85).
|
||||
- ``cookie_secure=True`` (E-4 handoff): the cookie is set with Secure
|
||||
because the public surface sits behind the TLS-fronted proxy.
|
||||
- Run artifacts + auth stores + key store + settings live under
|
||||
``RESEARCHX_DATA_DIR`` (container default ``/var/lib/researchx``,
|
||||
D-107) — never cwd-dependent.
|
||||
- Secrets come from the runtime env file (``.ciagent/.env.secrets``
|
||||
by default, overridable via ``RESEARCHX_SECRETS_FILE``; the
|
||||
container passes its own env-file via ``--env-file``):
|
||||
``RESEARCHX_KEK`` (keys scope) enables the per-user key store,
|
||||
``OLLAMA_API_KEY`` the server tier, ``TAVILY_API_KEY`` keyed search.
|
||||
- The model adapter is ALWAYS the real OllamaModelAdapter, constructed
|
||||
per run by the ModelAdapterFactory — this module can never serve
|
||||
FakeModelAdapter (pinned by test_main_entrypoint).
|
||||
- Search: TavilySearchAdapter when TAVILY_API_KEY is present, else the
|
||||
documented keyless DuckDuckGo fallback (degraded mode — G-008).
|
||||
- Single uvicorn worker only (in-memory RunRegistry + session store).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from researchx.adapters.duckduckgo import resolve_search_adapter
|
||||
from researchx.adapters.model_factory import ModelAdapterFactory
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.api.app import create_app
|
||||
from researchx.auth.key_store import KeyStore
|
||||
from researchx.auth.settings import SettingsStore
|
||||
from researchx.config.paths import data_root
|
||||
|
||||
_DEFAULT_SECRETS_PATHS = [
|
||||
Path("/etc/researchx/env.secrets"), # container convention (runtime bind)
|
||||
Path.cwd() / ".ciagent" / ".env.secrets", # repo-local dev
|
||||
Path.cwd().parents[1] / ".ciagent" / ".env.secrets", # tests run from backend/
|
||||
]
|
||||
|
||||
|
||||
def _secrets_paths() -> list[Path]:
|
||||
"""Runtime env-file candidates (first existing wins per merge order)."""
|
||||
import os
|
||||
|
||||
override = os.environ.get("RESEARCHX_SECRETS_FILE")
|
||||
if override:
|
||||
return [Path(override)]
|
||||
return [p for p in _DEFAULT_SECRETS_PATHS if p.is_file()] or _DEFAULT_SECRETS_PATHS
|
||||
|
||||
|
||||
def build_app() -> FastAPI:
|
||||
"""Build the LIVE app: real adapters, real auth, data-root storage.
|
||||
|
||||
``auth_enabled`` and ``cookie_secure`` are not parameters — they
|
||||
are the production posture, fixed here (C3-16/REQ-85).
|
||||
"""
|
||||
secrets = EnvFileSecretsProvider(paths=_secrets_paths())
|
||||
root = data_root()
|
||||
|
||||
key_store = KeyStore(secrets, root)
|
||||
settings_store = SettingsStore(root)
|
||||
factory = ModelAdapterFactory(secrets, key_store)
|
||||
|
||||
# Tavily when keyed, documented keyless DuckDuckGo otherwise (G-008).
|
||||
search, _search_name = resolve_search_adapter(secrets)
|
||||
|
||||
return create_app(
|
||||
storage_dir=root,
|
||||
data_dir=root,
|
||||
search_adapter=search,
|
||||
secrets_provider=secrets,
|
||||
key_store=key_store,
|
||||
settings_store=settings_store,
|
||||
model_factory=factory,
|
||||
auth_enabled=True, # HARDWIRED — the parameter exists for the test factory only
|
||||
cookie_secure=True, # behind the TLS-front proxy (E-4)
|
||||
)
|
||||
|
||||
|
||||
# Module-level app for `uvicorn researchx.api.main:app`. This is the
|
||||
# production object; it is built from live wiring (never fakes).
|
||||
app = build_app()
|
||||
@@ -0,0 +1,505 @@
|
||||
"""Disk read-models (T-3.2, D-114, D-115 — REQ-76..79).
|
||||
|
||||
Pure functions over ``runs/<owner_id>/<run_id>/`` disk artifacts —
|
||||
NEVER the in-memory RunRegistry (D-115): every read endpoint stays
|
||||
correct after a restart with zero in-process actors (P-8 full
|
||||
closure). Payloads are shaped EXACTLY to the existing frontend types
|
||||
(``frontend/src/lib/types.ts``): ``ReportLibrary``/``ReportSummary``,
|
||||
``ReportConnection``, ``ProjectGraph`` nodes/edges, ``InboxItem``,
|
||||
``SuggestedQuery`` — camelCase, field-for-field, so T-3.9's surface
|
||||
swaps need zero component rewrites (REQ-54).
|
||||
|
||||
Categorization is deterministic + dumb by design: a methodology
|
||||
folder (``deep-report``/``rapid-answer``) plus topic tokens from plan
|
||||
sub-questions. No model calls, no synthesis — the same disk tree
|
||||
always categorizes identically.
|
||||
|
||||
Open-question semantics (D-114): inbox items are plan sub-questions
|
||||
of the user's runs, ``done`` iff the run reached DONE — anything
|
||||
else (post-sweep CANCELLED included) stays ``open``; suggestions are
|
||||
those open items joined with source run titles — never synthetic.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# --------------------------------------------------------------- wire shapes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportFolder:
|
||||
"""matches frontend ReportFolder — id == ReportSummary.category."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
def payload(self) -> dict[str, str]:
|
||||
return {"id": self.id, "name": self.name, "description": self.description}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportSummary:
|
||||
"""matches frontend ReportSummary — the library card."""
|
||||
|
||||
id: str
|
||||
runId: str
|
||||
title: str
|
||||
question: str
|
||||
methodologySlug: str
|
||||
category: str
|
||||
generatedAt: str
|
||||
citationCount: int
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"runId": self.runId,
|
||||
"title": self.title,
|
||||
"question": self.question,
|
||||
"methodologySlug": self.methodologySlug,
|
||||
"category": self.category,
|
||||
"generatedAt": self.generatedAt,
|
||||
"citationCount": self.citationCount,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportConnection:
|
||||
"""matches frontend ReportConnection — a shared-source/topic edge."""
|
||||
|
||||
fromReportId: str
|
||||
toReportId: str
|
||||
kind: str # "shared-sources" | "related-topic"
|
||||
label: str
|
||||
|
||||
def payload(self) -> dict[str, str]:
|
||||
return {
|
||||
"fromReportId": self.fromReportId,
|
||||
"toReportId": self.toReportId,
|
||||
"kind": self.kind,
|
||||
"label": self.label,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphNode:
|
||||
id: str
|
||||
topic: str
|
||||
linkedSources: list[str]
|
||||
findings: list[str]
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"topic": self.topic,
|
||||
"linkedSources": self.linkedSources,
|
||||
"findings": self.findings,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphEdge:
|
||||
fromId: str
|
||||
toId: str
|
||||
sharedSourceCount: int
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"fromId": self.fromId,
|
||||
"toId": self.toId,
|
||||
"sharedSourceCount": self.sharedSourceCount,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InboxItem:
|
||||
"""matches frontend InboxItem — "<run_id>#<idx>" ids (D-114)."""
|
||||
|
||||
id: str
|
||||
question: str
|
||||
sourceRunId: str
|
||||
status: str # "open" | "done" (in_progress is mock-only vocabulary)
|
||||
|
||||
def payload(self) -> dict[str, str]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"question": self.question,
|
||||
"sourceRunId": self.sourceRunId,
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SuggestedQuery:
|
||||
"""matches frontend SuggestedQuery — an open item + run title."""
|
||||
|
||||
id: str
|
||||
question: str
|
||||
sourceRunId: str
|
||||
sourceReportTitle: str
|
||||
|
||||
def payload(self) -> dict[str, str]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"question": self.question,
|
||||
"sourceRunId": self.sourceRunId,
|
||||
"sourceReportTitle": self.sourceReportTitle,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------- disk scanning
|
||||
|
||||
_METHOD_NAMES = {"rapid-answer": "Rapid Answer", "deep-report": "Deep Report"}
|
||||
_QUOTE_MAX = 200
|
||||
|
||||
|
||||
def _is_scannable(run_dir: Path) -> bool:
|
||||
"""Defensive scan filter (D-115): dotfiles/tmp leftovers skipped."""
|
||||
name = run_dir.name
|
||||
return run_dir.is_dir() and not name.startswith(".") and ".tmp" not in name
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunDiskRecord:
|
||||
"""One run as it sits on disk — the read-model's only input."""
|
||||
|
||||
run_id: str
|
||||
state: dict[str, Any]
|
||||
report: dict[str, Any] | None = None
|
||||
ledger: dict[str, Any] | None = None
|
||||
# derived, filled by load:
|
||||
status: str = field(default="", init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.status = str(self.state.get("state", ""))
|
||||
|
||||
@property
|
||||
def is_done(self) -> bool:
|
||||
return self.status == "DONE"
|
||||
|
||||
@property
|
||||
def question(self) -> str:
|
||||
return str(self.state.get("question", ""))
|
||||
|
||||
@property
|
||||
def methodology_slug(self) -> str:
|
||||
return str(self.state.get("methodology_slug", ""))
|
||||
|
||||
@property
|
||||
def created_at(self) -> str:
|
||||
return str(self.state.get("created_at", ""))
|
||||
|
||||
@property
|
||||
def sub_questions(self) -> list[str]:
|
||||
plan = self.state.get("plan")
|
||||
if not isinstance(plan, dict):
|
||||
return []
|
||||
raw = plan.get("sub_questions", [])
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [str(q) for q in raw]
|
||||
|
||||
def report_title(self) -> str:
|
||||
"""The report.json title, else methodology-name — question."""
|
||||
if self.report is not None and self.report.get("title"):
|
||||
return str(self.report["title"])
|
||||
name = _METHOD_NAMES.get(self.methodology_slug, "Research")
|
||||
return f"{name} — {self.question}"
|
||||
|
||||
def citation_count(self) -> int:
|
||||
if self.report is not None:
|
||||
citations = self.report.get("citations")
|
||||
if isinstance(citations, list):
|
||||
return len(citations)
|
||||
return 0
|
||||
|
||||
def ledger_urls(self) -> list[str]:
|
||||
if self.ledger is None:
|
||||
return []
|
||||
entries = self.ledger.get("entries")
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
return [str(e.get("url")) for e in entries if isinstance(e, dict) and e.get("url")]
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
raw: object = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return raw if isinstance(raw, dict) else None
|
||||
|
||||
|
||||
def list_runs(base_dir: Path, owner_id: str) -> list[RunDiskRecord]:
|
||||
"""Scan this owner's partition: state.json (+ report.json,
|
||||
ledger.json where present). Dotfiles/tmp skipped defensively
|
||||
(D-115); corrupt or stateless directories are skipped, never
|
||||
fatal. Records are ordered most-recent first (created_at desc,
|
||||
run_id as the stable tiebreaker)."""
|
||||
runs_dir = base_dir / "runs" / owner_id
|
||||
if not runs_dir.is_dir():
|
||||
return []
|
||||
records: list[RunDiskRecord] = []
|
||||
for run_dir in sorted(runs_dir.iterdir()):
|
||||
if not _is_scannable(run_dir):
|
||||
continue
|
||||
state = _read_json(run_dir / "state.json")
|
||||
if state is None:
|
||||
continue
|
||||
records.append(
|
||||
RunDiskRecord(
|
||||
run_id=run_dir.name,
|
||||
state=state,
|
||||
report=_read_json(run_dir / "report.json"),
|
||||
ledger=_read_json(run_dir / "ledger.json"),
|
||||
)
|
||||
)
|
||||
records.sort(key=lambda r: (r.created_at, r.run_id), reverse=True)
|
||||
return records
|
||||
|
||||
|
||||
# ------------------------------------------------------- categorization (dumb)
|
||||
|
||||
_STOPWORDS = {
|
||||
"the", "a", "an", "of", "in", "on", "and", "or", "to", "for", "is",
|
||||
"are", "what", "which", "how", "do", "does", "why", "when", "with",
|
||||
"that", "this", "these", "those", "it", "its", "by", "at", "be",
|
||||
"can", "could", "should", "will", "into", "from", "about", "across",
|
||||
}
|
||||
|
||||
_TOPIC_TOKEN_RE = re.compile(r"[a-z][a-z0-9-]*")
|
||||
|
||||
|
||||
def topic_tokens(record: RunDiskRecord) -> list[str]:
|
||||
"""Deterministic topic tokens: salient words of the plan
|
||||
sub-questions (lowercased, deduped, order-preserving)."""
|
||||
tokens: list[str] = []
|
||||
for question in record.sub_questions:
|
||||
for token in _TOPIC_TOKEN_RE.findall(question.lower()):
|
||||
if token not in _STOPWORDS and token not in tokens:
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def categorize(record: RunDiskRecord) -> str:
|
||||
"""The folder id a report files under: methodology + topic token.
|
||||
|
||||
Deterministic + dumb (REQ-76): the methodology forms the folder
|
||||
base and the first topic token of the plan names the sub-folder —
|
||||
the same disk tree always categorizes identically. Runs without a
|
||||
plan (pre-approval) fall back to the bare methodology slug; a run
|
||||
with neither resolves to "research".
|
||||
"""
|
||||
slug = record.methodology_slug or "research"
|
||||
tokens = topic_tokens(record)
|
||||
if not tokens:
|
||||
return slug
|
||||
return f"{slug}-{tokens[0]}"
|
||||
|
||||
|
||||
def _category_name(category: str) -> str:
|
||||
"""Humanize a category id: 'deep-report-battery' -> 'Deep Report:
|
||||
Battery'. The methodology slug (multi-hyphen, e.g.
|
||||
'deep-report') is stripped as a unit before the topic token is
|
||||
titled."""
|
||||
for slug, name in _METHOD_NAMES.items():
|
||||
prefix = f"{slug}-"
|
||||
if category.startswith(prefix):
|
||||
tail = category[len(prefix) :]
|
||||
return f"{name}: {tail.replace('-', ' ').title()}" if tail else name
|
||||
return category.replace("-", " ").title()
|
||||
|
||||
|
||||
def _category_description(category: str) -> str:
|
||||
return f"Auto-filed from {category.replace('-', ' ')} research."
|
||||
|
||||
|
||||
def _summary(record: RunDiskRecord) -> ReportSummary:
|
||||
generated_at = (
|
||||
str(record.report.get("generated_at")) if record.report is not None else record.created_at
|
||||
)
|
||||
return ReportSummary(
|
||||
id=f"rep-{record.run_id}",
|
||||
runId=record.run_id,
|
||||
title=record.report_title(),
|
||||
question=record.question,
|
||||
methodologySlug=record.methodology_slug,
|
||||
category=categorize(record),
|
||||
generatedAt=generated_at or datetime.now(UTC).isoformat(),
|
||||
citationCount=record.citation_count(),
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- library
|
||||
|
||||
|
||||
def library(base_dir: Path, owner_id: str, sort: str = "recent") -> dict[str, Any]:
|
||||
"""GET /api/library payload: folders + ReportSummary[] (REQ-76).
|
||||
|
||||
Only DONE runs with a report are "stored reports" — a cancelled or
|
||||
failed run produced nothing worth filing. ``sort`` is "recent"
|
||||
(default, created_at desc — the scan order) or "title" (A-Z).
|
||||
"""
|
||||
if sort not in ("recent", "title"):
|
||||
sort = "recent"
|
||||
done = [r for r in list_runs(base_dir, owner_id) if r.is_done]
|
||||
reports = [_summary(r) for r in done]
|
||||
if sort == "title":
|
||||
reports.sort(key=lambda summary: (summary.title.casefold(), summary.runId))
|
||||
seen: dict[str, int] = {}
|
||||
for summary in reports:
|
||||
seen[summary.category] = seen.get(summary.category, 0) + 1
|
||||
folders = [
|
||||
ReportFolder(
|
||||
id=category,
|
||||
name=_category_name(category),
|
||||
description=_category_description(category),
|
||||
).payload()
|
||||
for category in sorted(seen)
|
||||
]
|
||||
return {
|
||||
"folders": folders,
|
||||
"reports": [summary.payload() for summary in reports],
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------ connections
|
||||
|
||||
|
||||
def _shared_urls(first: RunDiskRecord, second: RunDiskRecord) -> set[str]:
|
||||
return set(first.ledger_urls()) & set(second.ledger_urls())
|
||||
|
||||
|
||||
def connections(base_dir: Path, owner_id: str) -> list[dict[str, Any]]:
|
||||
"""GET /api/library/connections payload (REQ-77): shared-source
|
||||
edges (>=1 ledger URL overlap) + related-topic edges (same
|
||||
methodology + shared category). Between the user's DONE runs
|
||||
only; edges point older -> newer (the fixture convention).
|
||||
"""
|
||||
done = list(reversed([r for r in list_runs(base_dir, owner_id) if r.is_done]))
|
||||
edges: list[ReportConnection] = []
|
||||
for i, first in enumerate(done):
|
||||
for second in done[i + 1 :]:
|
||||
shared = _shared_urls(first, second)
|
||||
if shared:
|
||||
edges.append(
|
||||
ReportConnection(
|
||||
fromReportId=f"rep-{first.run_id}",
|
||||
toReportId=f"rep-{second.run_id}",
|
||||
kind="shared-sources",
|
||||
label=f"{len(shared)} shared source{'s' if len(shared) != 1 else ''}",
|
||||
)
|
||||
)
|
||||
elif categorize(first) == categorize(second):
|
||||
edges.append(
|
||||
ReportConnection(
|
||||
fromReportId=f"rep-{first.run_id}",
|
||||
toReportId=f"rep-{second.run_id}",
|
||||
kind="related-topic",
|
||||
label="Adjacent topic",
|
||||
)
|
||||
)
|
||||
return [edge.payload() for edge in edges]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- graph
|
||||
|
||||
|
||||
def _report_finding(record: RunDiskRecord) -> str:
|
||||
"""The node's first 'finding': the report's title (a real artifact
|
||||
value — never fabricated prose)."""
|
||||
return record.report_title()
|
||||
|
||||
|
||||
def graph(base_dir: Path, owner_id: str) -> dict[str, Any]:
|
||||
"""GET /api/graph payload (REQ-77): DONE runs as GraphNodes, the
|
||||
same edges as connections() as GraphEdges."""
|
||||
done = list(reversed([r for r in list_runs(base_dir, owner_id) if r.is_done]))
|
||||
nodes = [
|
||||
GraphNode(
|
||||
id=record.run_id,
|
||||
topic=record.report_title(),
|
||||
linkedSources=sorted(set(record.ledger_urls())),
|
||||
findings=[_report_finding(record)],
|
||||
).payload()
|
||||
for record in done
|
||||
]
|
||||
edges: list[GraphEdge] = []
|
||||
for i, first in enumerate(done):
|
||||
for second in done[i + 1 :]:
|
||||
shared = _shared_urls(first, second)
|
||||
if shared:
|
||||
edges.append(GraphEdge(first.run_id, second.run_id, len(shared)))
|
||||
elif categorize(first) == categorize(second):
|
||||
edges.append(GraphEdge(first.run_id, second.run_id, 0))
|
||||
return {"nodes": nodes, "edges": [edge.payload() for edge in edges]}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- tasks
|
||||
|
||||
|
||||
def tasks(base_dir: Path, owner_id: str) -> list[dict[str, str]]:
|
||||
"""GET /api/tasks payload (REQ-78, D-114): plan sub-questions as
|
||||
InboxItems, ``done`` iff the source run reached DONE — post-sweep
|
||||
CANCELLED runs surface ``open``, never answered."""
|
||||
items: list[InboxItem] = []
|
||||
for record in list_runs(base_dir, owner_id):
|
||||
status = "done" if record.is_done else "open"
|
||||
for idx, question in enumerate(record.sub_questions):
|
||||
items.append(
|
||||
InboxItem(
|
||||
id=f"{record.run_id}#{idx}",
|
||||
question=question,
|
||||
sourceRunId=record.run_id,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
return [item.payload() for item in items]
|
||||
|
||||
|
||||
# -------------------------------------------------------------- suggestions
|
||||
|
||||
|
||||
def suggestions(base_dir: Path, owner_id: str) -> list[dict[str, str]]:
|
||||
"""GET /api/suggestions payload (REQ-79): the OPEN inbox items
|
||||
joined with their source run titles — derived from the user's own
|
||||
runs only, never synthetic; empty for a fresh user."""
|
||||
suggested: list[SuggestedQuery] = []
|
||||
for record in list_runs(base_dir, owner_id):
|
||||
if record.is_done:
|
||||
continue
|
||||
for idx, question in enumerate(record.sub_questions):
|
||||
suggested.append(
|
||||
SuggestedQuery(
|
||||
id=f"sug-{record.run_id}#{idx}",
|
||||
question=question,
|
||||
sourceRunId=record.run_id,
|
||||
sourceReportTitle=record.report_title(),
|
||||
)
|
||||
)
|
||||
return [item.payload() for item in suggested]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GraphEdge",
|
||||
"GraphNode",
|
||||
"InboxItem",
|
||||
"ReportConnection",
|
||||
"ReportFolder",
|
||||
"ReportSummary",
|
||||
"RunDiskRecord",
|
||||
"SuggestedQuery",
|
||||
"categorize",
|
||||
"connections",
|
||||
"graph",
|
||||
"library",
|
||||
"list_runs",
|
||||
"suggestions",
|
||||
"tasks",
|
||||
"topic_tokens",
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Startup sweep (D-115, G-101).
|
||||
|
||||
On app construction, every non-terminal ``state.json`` under the
|
||||
storage base is rewritten as CANCELLED — in-flight actors died with the
|
||||
previous process; rehydration is out of v0.3 scope. CANCELLED is legal
|
||||
from every non-terminal state (states.py — including PLANNING per
|
||||
G-101, pinned by test). The registry-side pruning lives in app.py
|
||||
(RunRegistry.prune, P-6).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.engine.run_state import RunState
|
||||
from researchx.engine.states import TERMINAL_STATES, RunStateName
|
||||
|
||||
|
||||
def _atomic_write(path: Path, content: str) -> None:
|
||||
import os
|
||||
import secrets
|
||||
|
||||
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
tmp_path.replace(path)
|
||||
|
||||
|
||||
def sweep_in_flight_runs(base_dir: Path) -> list[tuple[str, str]]:
|
||||
"""Rewrite every non-terminal state.json under base_dir as CANCELLED.
|
||||
|
||||
Scans ``base_dir/runs/<owner_id>/<run_id>/state.json`` (the
|
||||
partitioned layout, D-106). Returns the swept (owner_id, run_id)
|
||||
pairs. Corrupt state files are skipped defensively — a sweep must
|
||||
never prevent startup.
|
||||
"""
|
||||
swept: list[tuple[str, str]] = []
|
||||
runs_dir = base_dir / "runs"
|
||||
if not runs_dir.is_dir():
|
||||
return swept
|
||||
for owner_dir in sorted(runs_dir.iterdir()):
|
||||
if not owner_dir.is_dir() or owner_dir.name.startswith("."):
|
||||
continue
|
||||
for run_dir in sorted(owner_dir.iterdir()):
|
||||
if not run_dir.is_dir() or run_dir.name.startswith("."):
|
||||
continue
|
||||
state_path = run_dir / "state.json"
|
||||
if not state_path.is_file():
|
||||
continue
|
||||
try:
|
||||
state = RunState.model_validate_json(state_path.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError):
|
||||
# Corrupt/unreadable state files are not swept, never
|
||||
# fatal — a sweep must never prevent startup.
|
||||
continue
|
||||
if state.state in TERMINAL_STATES:
|
||||
continue
|
||||
state.state = RunStateName.CANCELLED # legal from every non-terminal state (G-101)
|
||||
_atomic_write(state_path, state.model_dump_json())
|
||||
swept.append((owner_dir.name, run_dir.name))
|
||||
return swept
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Auth package: user accounts, sessions, guard, and routes (REQ-69/70/71)."""
|
||||
|
||||
from researchx.auth.models import (
|
||||
AdminCreateUserBody,
|
||||
LoginBody,
|
||||
RegisterBody,
|
||||
UserPublic,
|
||||
to_public,
|
||||
)
|
||||
from researchx.auth.sessions import Session, SessionStore
|
||||
from researchx.auth.users import User, UserError, UserStore
|
||||
|
||||
__all__ = [
|
||||
"AdminCreateUserBody",
|
||||
"LoginBody",
|
||||
"RegisterBody",
|
||||
"Session",
|
||||
"SessionStore",
|
||||
"User",
|
||||
"UserError",
|
||||
"UserPublic",
|
||||
"UserStore",
|
||||
"to_public",
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Admin user-management routes (T-1.5, REQ-70).
|
||||
|
||||
Mounted on the PROTECTED router (session + CSRF already enforced by
|
||||
``require_user``); every route additionally requires the admin role.
|
||||
Responses expose ``UserPublic`` only — never a password hash. Account
|
||||
creation enforces the same >=12 password policy as registration;
|
||||
deactivation sets ``active=False`` and immediately revokes every
|
||||
session of the target user (data is retained).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from researchx.auth.guard import Guard
|
||||
from researchx.auth.models import AdminCreateUserBody, UserPublic, to_public
|
||||
from researchx.auth.sessions import SessionStore
|
||||
from researchx.auth.users import UserError, UserStore
|
||||
|
||||
|
||||
def build_admin_router(
|
||||
users: UserStore,
|
||||
sessions: SessionStore,
|
||||
admin_guard: Guard,
|
||||
) -> APIRouter:
|
||||
"""Build the admin user surface (mounted at /api/admin, admin-only)."""
|
||||
router = APIRouter(
|
||||
prefix="/api/admin",
|
||||
tags=["admin"],
|
||||
dependencies=[Depends(admin_guard)],
|
||||
)
|
||||
|
||||
@router.get("/users", response_model=list[UserPublic])
|
||||
async def list_users() -> list[UserPublic]:
|
||||
return [to_public(u) for u in users.list()]
|
||||
|
||||
@router.post("/users", status_code=201, response_model=UserPublic)
|
||||
async def create_user(body: AdminCreateUserBody) -> UserPublic:
|
||||
try:
|
||||
user = await users.create_user(
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
display_name=body.display_name or body.email.split("@")[0],
|
||||
role=body.role,
|
||||
)
|
||||
except UserError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return to_public(user)
|
||||
|
||||
@router.post("/users/{user_id}/deactivate", response_model=UserPublic)
|
||||
async def deactivate_user(user_id: str) -> UserPublic:
|
||||
updated = users.deactivate(user_id)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="user not found")
|
||||
sessions.revoke_all_for(user_id) # deactivation kills sessions immediately
|
||||
return to_public(updated)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Auth guard — session + CSRF enforcement as route dependencies (T-1.4).
|
||||
|
||||
Deliberately NOT middleware: router dependencies appear per-route, are
|
||||
overridable via ``app.dependency_overrides``, and never wrap SSE
|
||||
response bodies (no BaseHTTPMiddleware near the event stream).
|
||||
|
||||
Enforcement order:
|
||||
1. missing/invalid/expired session -> 401 "authentication required"
|
||||
2. non-GET without the CSRF header -> 403 "missing request header"
|
||||
3. missing or deactivated user -> 401 "account disabled"
|
||||
then the sliding-expiry ``touch()``.
|
||||
|
||||
When auth is disabled (dev/test harness only — a create_app parameter,
|
||||
never an env var readable in production, C3-16), the dev guard
|
||||
resolves a synthetic admin user for every request so all routes behave
|
||||
exactly as authenticated.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from researchx.auth.models import UserPublic, to_public
|
||||
from researchx.auth.sessions import Session, SessionStore
|
||||
from researchx.auth.users import User, UserStore
|
||||
|
||||
SESSION_COOKIE = "researchx_session"
|
||||
CSRF_HEADER = "x-researchx-request"
|
||||
|
||||
DEV_USER_ID = "dev00000"
|
||||
|
||||
Guard = Callable[[Request], Awaitable[UserPublic]]
|
||||
|
||||
|
||||
def _dev_user() -> User:
|
||||
"""The synthetic admin every request resolves when auth is disabled."""
|
||||
return User(
|
||||
user_id=DEV_USER_ID,
|
||||
email="dev@localhost",
|
||||
password_hash="", # never persisted, never verified
|
||||
display_name="Dev User",
|
||||
role="admin",
|
||||
active=True,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def require_csrf_header(request: Request) -> None:
|
||||
"""CSRF defense for public POSTs (login/register) — same rule as the guard."""
|
||||
if request.method != "GET" and CSRF_HEADER not in request.headers:
|
||||
raise HTTPException(status_code=403, detail="missing request header")
|
||||
|
||||
|
||||
def build_guard(users: UserStore, sessions: SessionStore) -> Guard:
|
||||
"""Build the authenticated-mode require_user dependency."""
|
||||
|
||||
async def require_user(request: Request) -> UserPublic:
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
session: Session | None = sessions.get(token) if token else None
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="authentication required")
|
||||
if request.method != "GET" and CSRF_HEADER not in request.headers:
|
||||
raise HTTPException(status_code=403, detail="missing request header")
|
||||
user: User | None = users.get(session.user_id)
|
||||
if user is None or not user.active:
|
||||
raise HTTPException(status_code=401, detail="account disabled")
|
||||
sessions.touch(session) # sliding expiry
|
||||
return to_public(user)
|
||||
|
||||
return require_user
|
||||
|
||||
|
||||
def build_dev_guard() -> Guard:
|
||||
"""Build the dev-mode guard: every request resolves the synthetic dev user."""
|
||||
|
||||
async def require_user(request: Request) -> UserPublic:
|
||||
del request # dev mode: no session, no CSRF — routes behave as before
|
||||
return to_public(_dev_user())
|
||||
|
||||
return require_user
|
||||
|
||||
|
||||
def build_admin_guard(guard: Guard) -> Guard:
|
||||
"""Wrap a guard with the admin role check (REQ-70).
|
||||
|
||||
The returned dependency resolves the wrapped guard first, so
|
||||
``app.dependency_overrides`` on either composes correctly.
|
||||
"""
|
||||
|
||||
async def require_admin(request: Request) -> UserPublic:
|
||||
user = await guard(request)
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="admin role required")
|
||||
return user
|
||||
|
||||
return require_admin
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CSRF_HEADER",
|
||||
"DEV_USER_ID",
|
||||
"SESSION_COOKIE",
|
||||
"Guard",
|
||||
"build_admin_guard",
|
||||
"build_dev_guard",
|
||||
"build_guard",
|
||||
"require_csrf_header",
|
||||
]
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Per-user encrypted key store (T-2.2, REQ-73 — D-108/D-109).
|
||||
|
||||
AES-256-GCM envelope encryption of per-user model keys:
|
||||
|
||||
- KEK: ``RESEARCHX_KEK`` from the env-file secrets **'keys' scope**,
|
||||
accepted as urlsafe-base64 or hex, exactly 32 bytes (AES-256). Decode
|
||||
or length errors raise typed ``AdapterError`` (never a silent
|
||||
fallback). With no KEK configured the store is DISABLED — honestly,
|
||||
with a reason string — and the credential chain degrades to
|
||||
server/keyless (C3-06).
|
||||
- AAD: ``user_id.encode()`` — a key file copied under another user_id
|
||||
fails ``InvalidTag`` at decrypt time (cross-user binding by
|
||||
construction).
|
||||
- Nonce: fresh ``os.urandom(12)`` per write; a GCM nonce must NEVER
|
||||
repeat under the same KEK.
|
||||
- File: ``keys/<user_id>.json`` under the data root — ``{v, alg,
|
||||
updated_at, entries}`` — written atomically (tmp + ``os.replace``)
|
||||
with mode 0600.
|
||||
- Crypto runs in ``asyncio.to_thread`` so the event loop is never
|
||||
blocked by file I/O.
|
||||
- Scope: ``OLLAMA_API_KEY`` only in v0.3 (D-109).
|
||||
|
||||
KEK-loss posture (G-105, binding):
|
||||
The KEK is NOT recoverable from anything in this repository or in
|
||||
the running process. If the owner loses it, every per-user
|
||||
encrypted key becomes unrecoverable — by design, that is the point
|
||||
of envelope encryption. The honest degradation is: the key store
|
||||
reports disabled, the resolution chain falls to server/keyless,
|
||||
users re-enter their keys under a re-provisioned KEK, and the owner
|
||||
MUST back up the KEK off-repo. Rotation tooling is out of v0.3
|
||||
scope (recorded). A changed KEK requires a process restart — the
|
||||
secrets provider loads once at construction.
|
||||
|
||||
Corruption is never a silent tier drop: ``InvalidTag`` (AAD tamper,
|
||||
wrong KEK, garbage ciphertext) and malformed envelopes both surface as
|
||||
typed errors (``KeyStoreError``), so the credential chain can report an
|
||||
honest error state instead of quietly falling back.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from researchx.adapters.secrets import SecretsProvider
|
||||
from researchx.adapters.types import AdapterError
|
||||
|
||||
KEK_NAME = "RESEARCHX_KEK"
|
||||
KEY_SCOPE = "OLLAMA_API_KEY"
|
||||
_NONCE_BYTES = 12
|
||||
_KEY_BYTES = 32
|
||||
|
||||
|
||||
class KeyStoreError(AdapterError):
|
||||
"""Typed key-store failure: corrupt/tampered envelope, bad KEK shape.
|
||||
|
||||
``kind`` lets callers (routes, factory, tests) distinguish the
|
||||
failure mode without parsing messages:
|
||||
|
||||
- ``disabled`` — no usable KEK; the store refuses honestly.
|
||||
- ``unreadable`` — the on-disk envelope is corrupt garbage.
|
||||
- ``tampered`` — decrypt failed (InvalidTag: wrong user AAD,
|
||||
wrong KEK, or bit-rot ciphertext). NEVER a
|
||||
silent None — the tier must not silently drop.
|
||||
- ``bad_kek`` — the configured KEK is malformed (decode/length).
|
||||
"""
|
||||
|
||||
def __init__(self, kind: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.kind = kind
|
||||
|
||||
|
||||
class _KeyEntry(BaseModel):
|
||||
nonce: str
|
||||
ct: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class _KeyEnvelope(BaseModel):
|
||||
v: int
|
||||
alg: str = "AES-256-GCM"
|
||||
updated_at: str
|
||||
entries: dict[str, _KeyEntry] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def load_kek_bytes(secrets: SecretsProvider) -> bytes | None:
|
||||
"""Resolve ``RESEARCHX_KEK`` to raw 32 key bytes.
|
||||
|
||||
Accepted encodings: urlsafe-base64 (with or without padding) or
|
||||
hex. Anything that decodes but does not yield exactly 32 bytes, or
|
||||
fails to decode at all, raises ``KeyStoreError(kind="bad_kek")``
|
||||
(the research-file's typed AdapterError contract). ``None`` is
|
||||
returned only when the KEK is simply absent — the honest disabled
|
||||
state, distinct from a broken one.
|
||||
"""
|
||||
raw = secrets.resolve(KEK_NAME)
|
||||
if not raw:
|
||||
return None
|
||||
raw = raw.strip()
|
||||
# Try hex first (unambiguous: hex keys are [0-9a-fA-F]{64}); then
|
||||
# urlsafe b64 with flexible padding.
|
||||
try:
|
||||
if len(raw) == _KEY_BYTES * 2 and all(c in "0123456789abcdefABCDEF" for c in raw):
|
||||
return bytes.fromhex(raw)
|
||||
except ValueError as exc: # pragma: no cover — fromhex on checked chars can't fail
|
||||
raise KeyStoreError("bad_kek", f"{KEK_NAME} is not valid hex") from exc
|
||||
padded = raw + "=" * (-len(raw) % 4)
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise KeyStoreError(
|
||||
"bad_kek", f"{KEK_NAME} is not valid urlsafe-b64 or hex"
|
||||
) from exc
|
||||
if len(decoded) != _KEY_BYTES:
|
||||
raise KeyStoreError(
|
||||
"bad_kek", f"{KEK_NAME} must decode to exactly 32 bytes (AES-256), got {len(decoded)}"
|
||||
)
|
||||
return decoded
|
||||
|
||||
|
||||
def _encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode(token: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
|
||||
|
||||
|
||||
class KeyStore:
|
||||
"""Per-user encrypted key store (``keys/<user_id>.json``).
|
||||
|
||||
The store is ENABLED iff a valid KEK resolves at construction.
|
||||
Every public method is async; file/crypto work runs in
|
||||
``asyncio.to_thread``.
|
||||
"""
|
||||
|
||||
def __init__(self, secrets: SecretsProvider, data_root: Path) -> None:
|
||||
self._secrets = secrets
|
||||
self._root = data_root / "keys"
|
||||
self._lock = threading.Lock()
|
||||
self._kek: bytes | None
|
||||
self._disabled_reason: str | None = None
|
||||
try:
|
||||
self._kek = load_kek_bytes(secrets)
|
||||
except KeyStoreError as exc:
|
||||
# A malformed KEK is a hard misconfiguration: treat the
|
||||
# store as disabled with the honest reason, never a crash
|
||||
# and never a silent enable with the wrong key.
|
||||
self._kek = None
|
||||
self._disabled_reason = f"{KEK_NAME} is misconfigured: {exc}"
|
||||
if self._kek is None and self._disabled_reason is None:
|
||||
self._disabled_reason = (
|
||||
f"no {KEK_NAME} configured (env-file secrets 'keys' scope); "
|
||||
"per-user model keys cannot be stored — the credential chain "
|
||||
"falls to the server tier or keyless localhost"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------ state
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._kek is not None
|
||||
|
||||
@property
|
||||
def disabled_reason(self) -> str | None:
|
||||
return self._disabled_reason
|
||||
|
||||
def _require_kek(self) -> bytes:
|
||||
assert self._kek is not None # internal invariant; callers gate on enabled
|
||||
return self._kek
|
||||
|
||||
def _path(self, user_id: str) -> Path:
|
||||
return self._root / f"{user_id}.json"
|
||||
|
||||
# ------------------------------------------------------- sync workers
|
||||
|
||||
def _read_envelope(self, user_id: str) -> _KeyEnvelope | None:
|
||||
path = self._path(user_id)
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
return _KeyEnvelope.model_validate_json(raw)
|
||||
except ValidationError as exc:
|
||||
raise KeyStoreError(
|
||||
"unreadable",
|
||||
f"key envelope for user {user_id} is corrupt ({exc.error_count()} errors)",
|
||||
) from exc
|
||||
|
||||
def _set_key_locked(self, user_id: str, raw_key: str) -> None:
|
||||
kek = self._require_kek()
|
||||
nonce = os.urandom(_NONCE_BYTES) # fresh per write — NEVER reuse under the KEK
|
||||
ct = AESGCM(kek).encrypt(nonce, raw_key.encode("utf-8"), user_id.encode("utf-8"))
|
||||
envelope = _KeyEnvelope(
|
||||
v=1,
|
||||
alg="AES-256-GCM",
|
||||
updated_at=_now(),
|
||||
entries={KEY_SCOPE: _KeyEntry(nonce=_encode(nonce), ct=_encode(ct), updated_at=_now())},
|
||||
)
|
||||
path = self._path(user_id)
|
||||
self._root.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(envelope.model_dump_json(indent=2))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, path) # atomic — a crash never leaves a torn file
|
||||
|
||||
def _get_key_locked(self, user_id: str) -> str | None:
|
||||
envelope = self._read_envelope(user_id)
|
||||
if envelope is None:
|
||||
return None
|
||||
entry = envelope.entries.get(KEY_SCOPE)
|
||||
if entry is None:
|
||||
return None
|
||||
kek = self._require_kek()
|
||||
try:
|
||||
plain = AESGCM(kek).decrypt(
|
||||
_decode(entry.nonce), _decode(entry.ct), user_id.encode("utf-8")
|
||||
)
|
||||
except InvalidTag as exc:
|
||||
# AAD binding (user_id) failed, the KEK changed, or the
|
||||
# ciphertext bit-rotted. This is a typed error, NEVER a
|
||||
# silent None — a silent drop would let the tier fall to
|
||||
# server/keyless without the user knowing their key is
|
||||
# unreadable (C3-06).
|
||||
raise KeyStoreError(
|
||||
"tampered",
|
||||
f"key envelope for user {user_id} failed authentication "
|
||||
"(wrong user binding, changed KEK, or tampering)",
|
||||
) from exc
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise KeyStoreError(
|
||||
"unreadable", f"key envelope for user {user_id} has invalid encoding"
|
||||
) from exc
|
||||
return plain.decode("utf-8")
|
||||
|
||||
# ------------------------------------------------------------ API
|
||||
|
||||
async def set_key(self, user_id: str, scope: str, raw_key: str) -> None:
|
||||
"""Encrypt and persist *raw_key* for *user_id* under *scope*.
|
||||
|
||||
Rejected with a typed error when the store is disabled — an
|
||||
honest refusal, never a silent no-op.
|
||||
"""
|
||||
if scope != KEY_SCOPE:
|
||||
raise KeyStoreError("disabled", f"unsupported key scope: {scope} (v0.3: {KEY_SCOPE} only)")
|
||||
if not self.enabled:
|
||||
raise KeyStoreError("disabled", self._disabled_reason or "key store disabled")
|
||||
|
||||
def _work() -> None:
|
||||
with self._lock:
|
||||
self._set_key_locked(user_id, raw_key)
|
||||
|
||||
await asyncio.to_thread(_work)
|
||||
|
||||
async def get_key(self, user_id: str) -> str | None:
|
||||
"""Decrypt and return the user's key, or None when none stored.
|
||||
|
||||
Raises ``KeyStoreError`` on tamper/corruption (never a silent
|
||||
None), and when the store is disabled with a stored envelope
|
||||
that cannot be read.
|
||||
"""
|
||||
if not self.enabled:
|
||||
# Without a KEK nothing can be read — but an envelope that
|
||||
# EXISTS is surfaced as an error state (the user has a key
|
||||
# we cannot serve), not silently None.
|
||||
def _peek() -> bool:
|
||||
return self._path(user_id).is_file()
|
||||
|
||||
if await asyncio.to_thread(_peek):
|
||||
raise KeyStoreError(
|
||||
"disabled",
|
||||
f"a key is stored for user {user_id} but the key store is disabled ({self._disabled_reason})",
|
||||
)
|
||||
return None
|
||||
|
||||
def _work() -> str | None:
|
||||
with self._lock:
|
||||
return self._get_key_locked(user_id)
|
||||
|
||||
return await asyncio.to_thread(_work)
|
||||
|
||||
async def masked_status(self, user_id: str) -> dict[str, object]:
|
||||
"""Masked-only status: ``{configured, masked}`` — NEVER the raw key.
|
||||
|
||||
When the store is disabled the status says so honestly
|
||||
(``disabled: True`` + reason). A tampered/unreadable envelope
|
||||
is surfaced as an error field rather than ``configured: False``
|
||||
— never a silent tier drop.
|
||||
"""
|
||||
status: dict[str, object] = {"configured": False, "masked": None}
|
||||
if not self.enabled:
|
||||
status["disabled"] = True
|
||||
status["reason"] = self._disabled_reason
|
||||
|
||||
def _peek() -> bool:
|
||||
return self._path(user_id).is_file()
|
||||
|
||||
if await asyncio.to_thread(_peek):
|
||||
# An unreadable stored key is an error state, not "not configured".
|
||||
raise KeyStoreError(
|
||||
"disabled",
|
||||
f"a key is stored for user {user_id} but the key store is disabled ({self._disabled_reason})",
|
||||
)
|
||||
return status
|
||||
|
||||
def _work() -> dict[str, object]:
|
||||
with self._lock:
|
||||
envelope = self._read_envelope(user_id)
|
||||
if envelope is None or envelope.entries.get(KEY_SCOPE) is None:
|
||||
return {"configured": False, "masked": None, "disabled": False}
|
||||
key = self._get_key_locked(user_id) # surfaces tamper as KeyStoreError
|
||||
return {
|
||||
"configured": True,
|
||||
"masked": f"…{key[-4:]}" if key and len(key) >= 4 else "…",
|
||||
"disabled": False,
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_work)
|
||||
|
||||
|
||||
__all__ = ["KEK_NAME", "KEY_SCOPE", "KeyStore", "KeyStoreError", "load_kek_bytes"]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Auth API request/response contracts (T-1.3).
|
||||
|
||||
These are the wire models for the register/login/logout/me routes and
|
||||
the admin user surface. Password hashes never appear in any of them
|
||||
(REQ-69: no password-hash material in any API response).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from researchx.auth.users import MIN_PASSWORD_LENGTH, User
|
||||
|
||||
# Lightweight email shape check — the auth surface needs a stable,
|
||||
# normalizable identifier, not a full RFC 5322 validation (and pulling
|
||||
# pydantic[email] would add an unplanned dependency).
|
||||
_EMAIL_RE = r"^[^\s@]+@[^\s@]+\.[^\s@]+$"
|
||||
|
||||
|
||||
class RegisterBody(BaseModel):
|
||||
email: str = Field(pattern=_EMAIL_RE, max_length=254)
|
||||
password: str = Field(min_length=MIN_PASSWORD_LENGTH)
|
||||
display_name: str = Field(default="", max_length=120)
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
email: str = Field(pattern=_EMAIL_RE, max_length=254)
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AdminCreateUserBody(BaseModel):
|
||||
email: str = Field(pattern=_EMAIL_RE, max_length=254)
|
||||
password: str = Field(min_length=MIN_PASSWORD_LENGTH)
|
||||
display_name: str = Field(default="", max_length=120)
|
||||
role: str = Field(default="user", pattern=r"^(admin|user)$")
|
||||
|
||||
|
||||
class UserPublic(BaseModel):
|
||||
"""A user as exposed on the wire — no password_hash, ever."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
def to_public(user: User) -> UserPublic:
|
||||
"""Project a stored ``User`` to its public wire shape."""
|
||||
return UserPublic(
|
||||
user_id=user.user_id,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
role=user.role,
|
||||
active=user.active,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Auth routes: register / login / logout / me (T-1.4, REQ-69/70).
|
||||
|
||||
Mounted on a PUBLIC router at /api/auth/* (no require_user dependency)
|
||||
— but login/register POSTs still enforce the CSRF header (C3-05) via
|
||||
``Depends(require_csrf_header)``.
|
||||
|
||||
Route semantics:
|
||||
- register: open only while the user store is empty; the first user
|
||||
becomes admin, everything else 403s. The only await between the
|
||||
emptiness check and the insert is the argon2 hash; re-checking under
|
||||
the store lock keeps it race-free (D-104, single worker by design).
|
||||
- login: uniform 401 "invalid email or password" for every failure
|
||||
mode (no user enumeration); a fresh token is issued every login
|
||||
(session-fixation defense by rotation); the httpOnly cookie is set
|
||||
with SameSite=Lax, Path=/, no Domain attribute, Secure when
|
||||
configured for TLS-fronted hosts.
|
||||
- logout: revokes the presented token immediately.
|
||||
- me: the current user (session only — no CSRF header on GETs).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
||||
from researchx.auth.guard import SESSION_COOKIE, require_csrf_header
|
||||
from researchx.auth.models import LoginBody, RegisterBody, UserPublic, to_public
|
||||
from researchx.auth.sessions import SessionStore
|
||||
from researchx.auth.users import UserError, UserStore
|
||||
|
||||
_COOKIE_MAX_AGE = 14 * 24 * 3600
|
||||
|
||||
|
||||
def build_auth_router(
|
||||
users: UserStore,
|
||||
sessions: SessionStore,
|
||||
cookie_secure: bool,
|
||||
) -> APIRouter:
|
||||
"""Build the public auth router (mounted at /api/auth)."""
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
secure=cookie_secure,
|
||||
)
|
||||
|
||||
def _presented_token(request: Request) -> str | None:
|
||||
return request.cookies.get(SESSION_COOKIE)
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
status_code=201,
|
||||
response_model=UserPublic,
|
||||
dependencies=[Depends(require_csrf_header)],
|
||||
)
|
||||
async def register(body: RegisterBody) -> UserPublic:
|
||||
# Fast-path 403 for the common case; the authoritative
|
||||
# emptiness re-check happens under the store lock inside
|
||||
# create_first_admin (D-104) so a concurrent duplicate
|
||||
# register cannot both succeed.
|
||||
if not users.empty():
|
||||
raise HTTPException(status_code=403, detail="registration closed")
|
||||
try:
|
||||
user = await users.create_first_admin(
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
display_name=body.display_name or body.email.split("@")[0],
|
||||
)
|
||||
except UserError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
return to_public(user)
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=UserPublic,
|
||||
dependencies=[Depends(require_csrf_header)],
|
||||
)
|
||||
async def login(body: LoginBody, response: Response) -> UserPublic:
|
||||
user = users.get_by_email(body.email)
|
||||
if user is None or not await users.verify_password(user, body.password):
|
||||
raise HTTPException(status_code=401, detail="invalid email or password")
|
||||
if not user.active:
|
||||
raise HTTPException(status_code=401, detail="invalid email or password")
|
||||
# Token rotation: a fresh token on every login kills session
|
||||
# fixation by construction (D-101).
|
||||
token = sessions.generate_token()
|
||||
sessions.create(token, user.user_id)
|
||||
_set_session_cookie(response, token)
|
||||
if await users.needs_rehash(user):
|
||||
await users.rehash_password(user, body.password)
|
||||
return to_public(user)
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
dependencies=[Depends(require_csrf_header)],
|
||||
)
|
||||
async def logout(request: Request, response: Response) -> dict[str, bool]:
|
||||
token = _presented_token(request)
|
||||
if token:
|
||||
sessions.revoke(token)
|
||||
response.delete_cookie(key=SESSION_COOKIE, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
@router.get("/me", response_model=UserPublic)
|
||||
async def me(request: Request) -> UserPublic:
|
||||
token = _presented_token(request)
|
||||
session = sessions.get(token) if token else None
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="authentication required")
|
||||
user = users.get(session.user_id)
|
||||
if user is None or not user.active:
|
||||
raise HTTPException(status_code=401, detail="authentication required")
|
||||
sessions.touch(session)
|
||||
return to_public(user)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Session store — server-side, opaque tokens, durable JSON mirror (D-101).
|
||||
|
||||
Tokens are ``secrets.token_urlsafe(32)``; only their sha256 hash is
|
||||
stored (in memory and in ``<data_root>/sessions.json``). Expiry is
|
||||
14-day sliding: every authenticated request calls ``touch()`` which
|
||||
pushes ``expires_at`` forward, persisting at most once per 60s.
|
||||
Revocation is immediate (logout deletes the presented token;
|
||||
deactivation deletes every token of the user).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
SESSION_TTL_SECONDS = 14 * 24 * 3600
|
||||
_PERSIST_THROTTLE_SECONDS = 60.0
|
||||
|
||||
|
||||
class Session(BaseModel):
|
||||
token_hash: str
|
||||
user_id: str
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
last_seen: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
expires_at: datetime
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""In-memory map keyed by token_hash + durable sessions.json mirror."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._path = path
|
||||
self._sessions: dict[str, Session] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._last_persist = 0.0
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------ persistence
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self._path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
for record in raw.get("sessions", []):
|
||||
try:
|
||||
session = Session.model_validate(record)
|
||||
except (ValidationError, KeyError, TypeError):
|
||||
continue # defensive: skip corrupt records on load
|
||||
self._sessions[session.token_hash] = session
|
||||
|
||||
def _persist_locked(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"sessions": [s.model_dump(mode="json") for s in self._sessions.values()]
|
||||
}
|
||||
tmp_path = self._path.with_name(f"{self._path.name}.{secrets.token_hex(4)}.tmp")
|
||||
content = json.dumps(payload, indent=2)
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, self._path)
|
||||
self._last_persist = time.monotonic()
|
||||
|
||||
def _persist(self) -> None:
|
||||
with self._lock:
|
||||
self._persist_locked()
|
||||
|
||||
# ------------------------------------------------------------- queries
|
||||
|
||||
def get(self, token: str) -> Session | None:
|
||||
"""Resolve a presented token; expired or unknown tokens yield None."""
|
||||
token_hash = _hash_token(token)
|
||||
session = self._sessions.get(token_hash)
|
||||
if session is None:
|
||||
return None
|
||||
if session.expires_at <= datetime.now(UTC):
|
||||
with self._lock:
|
||||
expired = self._sessions.pop(token_hash, None)
|
||||
if expired is not None:
|
||||
self._persist_locked()
|
||||
return None
|
||||
return session
|
||||
|
||||
# ------------------------------------------------------------- mutation
|
||||
|
||||
def create(self, token: str, user_id: str) -> Session:
|
||||
"""Store a new session for *token* (only its hash is kept)."""
|
||||
now = datetime.now(UTC)
|
||||
session = Session(
|
||||
token_hash=_hash_token(token),
|
||||
user_id=user_id,
|
||||
created_at=now,
|
||||
last_seen=now,
|
||||
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
|
||||
)
|
||||
with self._lock:
|
||||
self._sessions[session.token_hash] = session
|
||||
self._persist_locked()
|
||||
return session
|
||||
|
||||
def touch(self, session: Session) -> None:
|
||||
"""Sliding expiry: push expires_at forward, throttled persist."""
|
||||
now = datetime.now(UTC)
|
||||
renewed = Session(
|
||||
token_hash=session.token_hash,
|
||||
user_id=session.user_id,
|
||||
created_at=session.created_at,
|
||||
last_seen=now,
|
||||
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
|
||||
)
|
||||
with self._lock:
|
||||
current = self._sessions.get(session.token_hash)
|
||||
if current is None or current != session:
|
||||
return # stale or raced touch — nothing to extend
|
||||
self._sessions[renewed.token_hash] = renewed
|
||||
if time.monotonic() - self._last_persist > _PERSIST_THROTTLE_SECONDS:
|
||||
self._persist_locked()
|
||||
|
||||
def revoke(self, token: str) -> None:
|
||||
"""Immediately delete the session for *token* (logout)."""
|
||||
token_hash = _hash_token(token)
|
||||
with self._lock:
|
||||
if self._sessions.pop(token_hash, None) is not None:
|
||||
self._persist_locked()
|
||||
|
||||
def revoke_all_for(self, user_id: str) -> None:
|
||||
"""Immediately delete every session of *user_id* (deactivation)."""
|
||||
with self._lock:
|
||||
doomed = [h for h, s in self._sessions.items() if s.user_id == user_id]
|
||||
for token_hash in doomed:
|
||||
del self._sessions[token_hash]
|
||||
if doomed:
|
||||
self._persist_locked()
|
||||
|
||||
def generate_token(self) -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
# ----------------------------------------------------------- asyncio shim
|
||||
|
||||
async def arevoke(self, token: str) -> None:
|
||||
await asyncio.to_thread(self.revoke, token)
|
||||
|
||||
async def arevoke_all_for(self, user_id: str) -> None:
|
||||
await asyncio.to_thread(self.revoke_all_for, user_id)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Per-user settings persistence (T-2.3, REQ-72 — D-111).
|
||||
|
||||
``settings/<user_id>.json`` under the data root, written atomically
|
||||
(tmp + ``os.replace``). Fresh users get defaults on read (GET never
|
||||
writes). The ``UserSettings`` model carries the per-user surface the
|
||||
Settings page binds to: planner/writer model names, methodology
|
||||
defaults (resolved dial overrides per methodology — free-form dict in
|
||||
v0.3, validated at use time), and the appearance slot.
|
||||
|
||||
The storage directory is server-managed and display-only (D-111) — it
|
||||
is NOT part of this payload; the config status route surfaces it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from researchx.adapters.types import AdapterError
|
||||
|
||||
# Default model names — the same defaults the live smoke tier uses
|
||||
# (gpt-oss:20b on ollama.com). These are fallbacks only: the frontend
|
||||
# Settings page always shows the current value.
|
||||
DEFAULT_PLANNER_MODEL = "gpt-oss:20b"
|
||||
DEFAULT_WRITER_MODEL = "gpt-oss:20b"
|
||||
|
||||
|
||||
class UserSettings(BaseModel):
|
||||
planner_model: str = DEFAULT_PLANNER_MODEL
|
||||
writer_model: str = DEFAULT_WRITER_MODEL
|
||||
methodology_defaults: dict[str, Any] = Field(default_factory=dict)
|
||||
appearance: str = "system"
|
||||
|
||||
|
||||
class SettingsStoreError(AdapterError):
|
||||
"""Typed settings-store failure (corrupt on-disk payload)."""
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
"""Per-user settings, one JSON file per user, atomic writes."""
|
||||
|
||||
def __init__(self, data_root: Path) -> None:
|
||||
self._root = data_root / "settings"
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _path(self, user_id: str) -> Path:
|
||||
return self._root / f"{user_id}.json"
|
||||
|
||||
# ------------------------------------------------------------ sync
|
||||
|
||||
def _read_sync(self, user_id: str) -> UserSettings:
|
||||
path = self._path(user_id)
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return UserSettings() # fresh user: defaults, no write on read
|
||||
try:
|
||||
return UserSettings.model_validate_json(raw)
|
||||
except ValidationError as exc:
|
||||
raise SettingsStoreError(
|
||||
f"settings payload for user {user_id} is corrupt ({exc.error_count()} errors)"
|
||||
) from exc
|
||||
|
||||
def _update_sync(self, user_id: str, partial: UserSettings) -> UserSettings:
|
||||
current = self._read_sync(user_id)
|
||||
merged = current.model_copy(update=partial.model_dump(exclude_unset=True))
|
||||
# Validate the merged payload BEFORE it touches disk: a null
|
||||
# field slipped past model_copy (it skips validation) would
|
||||
# poison the file — every later read would raise corrupt.
|
||||
merged = UserSettings.model_validate(merged.model_dump())
|
||||
path = self._path(user_id)
|
||||
self._root.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(merged.model_dump_json(indent=2))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
tmp_path.replace(path) # atomic
|
||||
return merged
|
||||
|
||||
# ------------------------------------------------------------ async
|
||||
|
||||
async def get(self, user_id: str) -> UserSettings:
|
||||
return await asyncio.to_thread(self._read_sync, user_id)
|
||||
|
||||
async def update(self, user_id: str, partial: UserSettings) -> UserSettings:
|
||||
def _work() -> UserSettings:
|
||||
with self._lock:
|
||||
return self._update_sync(user_id, partial)
|
||||
|
||||
return await asyncio.to_thread(_work)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PLANNER_MODEL",
|
||||
"DEFAULT_WRITER_MODEL",
|
||||
"SettingsStore",
|
||||
"SettingsStoreError",
|
||||
"UserSettings",
|
||||
]
|
||||
@@ -0,0 +1,237 @@
|
||||
"""User store — durable JSON-backed account registry (REQ-69, D-102/D-105).
|
||||
|
||||
Persisted to ``<data_root>/users.json`` with the atomic tmp+``os.replace``
|
||||
idiom (D-017). Password hashing is argon2id via argon2-cffi
|
||||
``PasswordHasher()`` defaults; every hash/verify runs through
|
||||
``asyncio.to_thread`` because argon2 verify at default parameters costs
|
||||
~50-100ms of CPU and must leave the event loop alone.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from researchx.config.ids import generate_slug
|
||||
|
||||
MIN_PASSWORD_LENGTH = 12
|
||||
|
||||
|
||||
class UserError(ValueError):
|
||||
"""Raised for user-store policy violations (duplicate email, weak password)."""
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
user_id: str = Field(pattern=r"^[a-z0-9]{8}$")
|
||||
email: str
|
||||
password_hash: str
|
||||
display_name: str
|
||||
role: str = "user"
|
||||
active: bool = True
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class UserStore:
|
||||
"""In-memory map keyed by user_id + durable users.json mirror."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._path = path
|
||||
self._by_id: dict[str, User] = {}
|
||||
self._by_email: dict[str, User] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._hasher = PasswordHasher()
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------ persistence
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self._path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
# FAIL CLOSED on a corrupt user store. Swallowing it would
|
||||
# silently report an empty store, reopening registration on
|
||||
# top of existing accounts — the next persist would then
|
||||
# overwrite them (auth-reset-by-corruption). The file is
|
||||
# renamed aside so an operator can recover it; startup is
|
||||
# refused rather than silently degrading.
|
||||
salvage = self._path.with_name(f"{self._path.name}.corrupt.{secrets.token_hex(4)}")
|
||||
os.replace(self._path, salvage)
|
||||
raise RuntimeError(
|
||||
f"corrupt user store {self._path}; moved aside to {salvage}"
|
||||
) from exc
|
||||
for record in raw.get("users", []):
|
||||
try:
|
||||
user = User.model_validate(record)
|
||||
except (ValidationError, KeyError, TypeError):
|
||||
continue # defensive: skip corrupt records on load
|
||||
self._by_id[user.user_id] = user
|
||||
self._by_email[user.email] = user
|
||||
|
||||
def _persist_locked(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"users": [u.model_dump(mode="json") for u in self._by_id.values()]}
|
||||
tmp_path = self._path.with_name(f"{self._path.name}.{secrets.token_hex(4)}.tmp")
|
||||
content = json.dumps(payload, indent=2)
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, self._path)
|
||||
|
||||
def _persist(self) -> None:
|
||||
with self._lock:
|
||||
self._persist_locked()
|
||||
|
||||
# ------------------------------------------------------------- hashing
|
||||
|
||||
async def hash_password(self, password: str) -> str:
|
||||
return await asyncio.to_thread(self._hasher.hash, password)
|
||||
|
||||
async def verify_password(self, user: User, password: str) -> bool:
|
||||
try:
|
||||
await asyncio.to_thread(self._hasher.verify, user.password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def needs_rehash(self, user: User) -> bool:
|
||||
return await asyncio.to_thread(self._hasher.check_needs_rehash, user.password_hash)
|
||||
|
||||
# ------------------------------------------------------------- queries
|
||||
|
||||
def empty(self) -> bool:
|
||||
return not self._by_id
|
||||
|
||||
def get(self, user_id: str) -> User | None:
|
||||
return self._by_id.get(user_id)
|
||||
|
||||
def get_by_email(self, email: str) -> User | None:
|
||||
return self._by_email.get(email.casefold())
|
||||
|
||||
def list(self) -> list[User]:
|
||||
return sorted(self._by_id.values(), key=lambda u: (u.created_at, u.user_id))
|
||||
|
||||
# ------------------------------------------------------------- mutation
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: str,
|
||||
role: str = "user",
|
||||
) -> User:
|
||||
normalized = email.casefold()
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise UserError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
|
||||
if self._by_email.get(normalized) is not None:
|
||||
raise UserError("email already registered")
|
||||
password_hash = await self.hash_password(password)
|
||||
user = User(
|
||||
user_id=generate_slug(),
|
||||
email=normalized,
|
||||
password_hash=password_hash,
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
)
|
||||
with self._lock:
|
||||
if self._by_email.get(normalized) is not None:
|
||||
raise UserError("email already registered")
|
||||
self._by_id[user.user_id] = user
|
||||
self._by_email[user.email] = user
|
||||
self._persist_locked()
|
||||
return user
|
||||
|
||||
async def rehash_password(self, user: User, password: str) -> None:
|
||||
"""Rehash a user's password in place (login-time rehash on parameter drift)."""
|
||||
password_hash = await self.hash_password(password)
|
||||
with self._lock:
|
||||
updated = user.model_copy(update={"password_hash": password_hash})
|
||||
self._by_id[updated.user_id] = updated
|
||||
self._by_email[updated.email] = updated
|
||||
self._persist_locked()
|
||||
|
||||
async def update_profile(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
email: str | None = None,
|
||||
) -> User:
|
||||
"""Update display name and/or email (T-3.6 — GET/PUT /api/profile).
|
||||
|
||||
The email must remain unique across OTHER users (a taken
|
||||
address raises UserError); the email index is maintained
|
||||
atomically under the store lock. No deletion exists.
|
||||
"""
|
||||
with self._lock:
|
||||
user = self._by_id.get(user_id)
|
||||
if user is None:
|
||||
raise UserError("unknown user")
|
||||
new_email = user.email
|
||||
if email is not None:
|
||||
new_email = email.casefold()
|
||||
other = self._by_email.get(new_email)
|
||||
if other is not None and other.user_id != user_id:
|
||||
raise UserError("email already registered")
|
||||
new_display = display_name if display_name is not None else user.display_name
|
||||
updated = user.model_copy(update={"email": new_email, "display_name": new_display})
|
||||
if new_email != user.email:
|
||||
old = self._by_email.get(user.email)
|
||||
if old is not None and old.user_id == user_id:
|
||||
del self._by_email[user.email]
|
||||
self._by_id[user_id] = updated
|
||||
self._by_email[new_email] = updated
|
||||
self._persist_locked()
|
||||
return updated
|
||||
|
||||
async def create_first_admin(self, *, email: str, password: str, display_name: str) -> User:
|
||||
"""Atomically create the first user, who is admin by construction.
|
||||
|
||||
The emptiness check + insert both happen under the store lock;
|
||||
the only await (the argon2 hash) precedes the check. Combined
|
||||
with the single uvicorn worker this makes registration open
|
||||
exactly once (D-104).
|
||||
"""
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise UserError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
|
||||
normalized = email.casefold()
|
||||
password_hash = await self.hash_password(password) # the one allowed await
|
||||
with self._lock:
|
||||
if self._by_id:
|
||||
raise UserError("registration closed")
|
||||
if self._by_email.get(normalized) is not None:
|
||||
raise UserError("email already registered")
|
||||
user = User(
|
||||
user_id=generate_slug(),
|
||||
email=normalized,
|
||||
password_hash=password_hash,
|
||||
display_name=display_name,
|
||||
role="admin",
|
||||
)
|
||||
self._by_id[user.user_id] = user
|
||||
self._by_email[user.email] = user
|
||||
self._persist_locked()
|
||||
return user
|
||||
|
||||
def deactivate(self, user_id: str) -> User | None:
|
||||
with self._lock:
|
||||
user = self._by_id.get(user_id)
|
||||
if user is None:
|
||||
return None
|
||||
updated = user.model_copy(update={"active": False})
|
||||
self._by_id[user_id] = updated
|
||||
self._by_email[updated.email] = updated
|
||||
self._persist_locked()
|
||||
return updated
|
||||
@@ -0,0 +1,25 @@
|
||||
"""8-char slug ids — the single generator + validation (P-14 consolidation).
|
||||
|
||||
Run ids and user ids share one alphabet and one shape: 8 lowercase
|
||||
alphanumeric characters, validated by ``^[a-z0-9]{8}$`` (D-105 — the
|
||||
user_id doubles as a filesystem-safe partition key). Both
|
||||
``adapters.local_storage`` and ``config.run_config`` import from here so
|
||||
there is exactly one generator left in the tree.
|
||||
"""
|
||||
|
||||
import re
|
||||
import secrets
|
||||
|
||||
ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
|
||||
SLUG_RE = re.compile(r"^[a-z0-9]{8}$")
|
||||
|
||||
|
||||
def generate_slug() -> str:
|
||||
"""Generate a random 8-char slug from the shared alphabet."""
|
||||
return "".join(secrets.choice(ALPHABET) for _ in range(8))
|
||||
|
||||
|
||||
def is_valid_slug(value: str) -> bool:
|
||||
"""Return True when *value* is a well-formed 8-char slug."""
|
||||
return SLUG_RE.match(value) is not None
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Data-root resolution (D-107).
|
||||
|
||||
All mutable, host-persisted state (users.json, sessions.json, runs/,
|
||||
keys/, settings/) lives under one directory:
|
||||
|
||||
- ``RESEARCHX_DATA_DIR`` when set (container: ``/var/lib/researchx``),
|
||||
- else ``backend/var/researchx`` relative to the repository root (dev).
|
||||
|
||||
Replaces the cwd-dependent ``Path.cwd() / ".researchx"`` storage default.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_ENV_VAR = "RESEARCHX_DATA_DIR"
|
||||
|
||||
# src/researchx/config/paths.py -> repo root is four parents up.
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
_DEV_DEFAULT = _REPO_ROOT / "backend" / "var" / "researchx"
|
||||
|
||||
|
||||
def data_root() -> Path:
|
||||
"""Return the mutable data root, creating it on demand.
|
||||
|
||||
The directory (and its parent chain) is created lazily so that
|
||||
importing this module never touches the filesystem.
|
||||
"""
|
||||
raw = os.environ.get(_ENV_VAR)
|
||||
root = Path(raw).expanduser() if raw else _DEV_DEFAULT
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
@@ -1,4 +1,3 @@
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
@@ -6,6 +5,7 @@ from typing import Any
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from researchx.config.dials import Dials
|
||||
from researchx.config.ids import generate_slug
|
||||
from researchx.methodologies.registry import MethodologyRegistry
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ class RunConfig(BaseModel, frozen=True):
|
||||
resolved_dials: Dials
|
||||
created_at: datetime
|
||||
overrides: dict[str, Any] = Field(default_factory=dict)
|
||||
# Owning user (REQ-71). Optional = serialization-compatible with
|
||||
# pre-v0.3 config files (D-106: no migration).
|
||||
owner_id: str | None = None
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
@@ -43,7 +46,8 @@ def resolve_dials(
|
||||
|
||||
|
||||
def new_run_id() -> str:
|
||||
return secrets.token_hex(4)
|
||||
"""Generate a run id (P-14: single shared generator — config/ids.py)."""
|
||||
return generate_slug()
|
||||
|
||||
|
||||
__all__ = ["RunConfig", "UnknownOverrideError", "new_run_id", "resolve_dials"]
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -12,6 +11,7 @@ from researchx.adapters.types import AdapterError, ModelMessage
|
||||
from researchx.adapters.url_guard import url_allowed
|
||||
from researchx.config.dials import Dials
|
||||
from researchx.config.run_config import RunConfig, resolve_dials
|
||||
from researchx.engine.artifacts import build_evidence_artifact, build_report_artifact
|
||||
from researchx.engine.events import EventBus, RunEvent
|
||||
from researchx.engine.run_state import Plan, RunState
|
||||
from researchx.engine.states import RunStateName
|
||||
@@ -35,7 +35,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 +42,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 +84,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:
|
||||
@@ -333,6 +329,33 @@ class RunActor:
|
||||
)
|
||||
checklist = export_checklist(ledger)
|
||||
await self._storage.write_run_artifact(self._config.run_id, "checklist.md", checklist)
|
||||
# Structured artifacts (T-3.1, D-113): report.json +
|
||||
# evidence.json, written HERE — the only point where the
|
||||
# per-actor EvidenceStore still exists (it dies with the actor;
|
||||
# the disk read-models of D-114/D-115 can never rebuild it).
|
||||
# Marker normalization [p-N] -> [^pN] happens at this
|
||||
# persistence boundary ONLY (C3-10): report.md keeps the raw
|
||||
# form the trust-loop regexes depend on.
|
||||
evidence_artifact = build_evidence_artifact(
|
||||
run_id=self._config.run_id, evidence=self._store
|
||||
)
|
||||
report_artifact = build_report_artifact(
|
||||
run_id=self._config.run_id,
|
||||
owner_id=self.state.owner_id,
|
||||
question=self._config.question,
|
||||
methodology_name=self._methodology.name,
|
||||
methodology_slug=self._config.methodology_slug,
|
||||
report_markdown=report,
|
||||
ledger=ledger,
|
||||
evidence=self._store,
|
||||
generated_at=ledger.generated_at,
|
||||
)
|
||||
await self._storage.write_run_artifact(
|
||||
self._config.run_id, "evidence.json", evidence_artifact.model_dump_json(indent=2)
|
||||
)
|
||||
await self._storage.write_run_artifact(
|
||||
self._config.run_id, "report.json", report_artifact.model_dump_json(indent=2)
|
||||
)
|
||||
await self._checkpoint(RunStateName.DONE)
|
||||
await self._publish("run_done", {"run_id": self._config.run_id})
|
||||
|
||||
@@ -356,7 +379,11 @@ def make_run_config(
|
||||
overrides: dict[str, object],
|
||||
registry: MethodologyRegistry,
|
||||
run_id: str | None = None,
|
||||
owner_id: str | None = None,
|
||||
) -> tuple[str, RunConfig]:
|
||||
"""Build a run config; ``owner_id`` (when given) travels with the
|
||||
config and is stamped on the run state by the wiring layer (T-1.7).
|
||||
"""
|
||||
from researchx.adapters.local_storage import generate_run_id
|
||||
|
||||
rid = run_id or generate_run_id()
|
||||
@@ -369,5 +396,6 @@ def make_run_config(
|
||||
resolved_dials=dials,
|
||||
overrides=dict(overrides),
|
||||
created_at=datetime.now(UTC),
|
||||
owner_id=owner_id,
|
||||
)
|
||||
return rid, config
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Structured run artifacts (T-3.1, D-113): report.json + evidence.json.
|
||||
|
||||
Built at DONE inside ``RunActor._verify_phase`` — the ONLY point where
|
||||
the per-actor ``EvidenceStore`` is still alive (it is GC'd with the
|
||||
actor; D-115 disk read-models come later and can never see it).
|
||||
|
||||
**Marker normalization happens HERE ONLY (C3-10).** The writer emits
|
||||
``[p-N]`` — the trust-loop regexes (``anchoring.py``,
|
||||
``ledger.py``) and the actor's own ``_cited_urls`` depend on that raw
|
||||
form, so ``report.md`` is never touched. The Reading Room parses
|
||||
``[^pN]`` (``report-body.ts`` matches ``[A-Za-z0-9]+`` only —
|
||||
``[^p-1]`` does NOT match), therefore report.json section bodies and
|
||||
the id fields of both artifacts carry the normalized ``pN`` id form.
|
||||
|
||||
Both artifacts are versioned (``v: 1``) so later schema changes stay
|
||||
detectable per file (D-106 old artifacts stay disposable/readable).
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from researchx.evidence.store import EvidenceStore
|
||||
from researchx.trust.ledger import CitationLedger
|
||||
|
||||
#: The writer's raw marker form — the ONLY pattern normalized here.
|
||||
_MARKER_RE = re.compile(r"\[p-(\d+)\]")
|
||||
|
||||
#: ATX headings (H1..H6) — section delimiters for report.json.
|
||||
_HEADING_RE = re.compile(r"^#{1,6}[ \t]+(.*)$", re.MULTILINE)
|
||||
|
||||
#: A lone H1 — the report title source.
|
||||
_H1_RE = re.compile(r"^#[ \t]+(.*)$", re.MULTILINE)
|
||||
|
||||
#: Quote display cap — matches EvidenceStore's ``quote`` convention.
|
||||
_QUOTE_MAX_CHARS = 200
|
||||
|
||||
|
||||
def normalize_passage_id(passage_id: str) -> str:
|
||||
"""``p-N`` -> ``pN`` (the id form report.json/evidence.json carry)."""
|
||||
match = re.fullmatch(r"p-(\d+)", passage_id)
|
||||
return f"p{match.group(1)}" if match else passage_id
|
||||
|
||||
|
||||
def normalize_markers(text: str) -> str:
|
||||
"""``[p-N]`` -> ``[^pN]`` — report.json section bodies ONLY."""
|
||||
return _MARKER_RE.sub(lambda match: f"[^p{match.group(1)}]", text)
|
||||
|
||||
|
||||
def extract_title(markdown: str, fallback: str) -> str:
|
||||
"""First H1 of the report, else the methodology/question fallback."""
|
||||
match = _H1_RE.search(markdown)
|
||||
if match is not None:
|
||||
return match.group(1).strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def split_sections(markdown: str) -> list[tuple[str, str]]:
|
||||
"""Split report.md into (heading, body) chunks on ATX headings.
|
||||
|
||||
Every ``^#{1,6}\\s`` heading starts a section and is included as
|
||||
that section's heading (the H1 chunk included — the title is a
|
||||
separate field and the frontend binding decides presentation).
|
||||
Content before the first heading becomes a ``("", body)`` section
|
||||
rather than being dropped — no content loss, no fabrication.
|
||||
"""
|
||||
sections: list[tuple[str, str]] = []
|
||||
matches = list(_HEADING_RE.finditer(markdown))
|
||||
if not matches:
|
||||
body = markdown.strip()
|
||||
if body:
|
||||
sections.append(("", body))
|
||||
return sections
|
||||
preamble = markdown[: matches[0].start()].strip()
|
||||
if preamble:
|
||||
sections.append(("", preamble))
|
||||
for index, match in enumerate(matches):
|
||||
heading = match.group(1).strip()
|
||||
start = match.end()
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(markdown)
|
||||
sections.append((heading, markdown[start:end].strip()))
|
||||
return sections
|
||||
|
||||
|
||||
class EvidencePassage(BaseModel):
|
||||
"""One stored passage — id in the normalized ``pN`` form."""
|
||||
|
||||
passage_id: str
|
||||
url: str
|
||||
title: str
|
||||
quote: str
|
||||
text: str
|
||||
|
||||
|
||||
class EvidenceArtifact(BaseModel):
|
||||
"""evidence.json — the serialized EvidenceStore at DONE."""
|
||||
|
||||
v: int = 1
|
||||
run_id: str
|
||||
passages: list[EvidencePassage] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ReportSection(BaseModel):
|
||||
heading: str
|
||||
body: str
|
||||
|
||||
|
||||
class ReportCitation(BaseModel):
|
||||
"""A page-anchored citation — passage-level, quote included."""
|
||||
|
||||
passage_id: str # pN form
|
||||
url: str
|
||||
title: str
|
||||
quote: str
|
||||
health: str
|
||||
authority_tier: str
|
||||
|
||||
|
||||
class ReportArtifact(BaseModel):
|
||||
"""report.json — the structured Reading-Room payload at DONE."""
|
||||
|
||||
v: int = 1
|
||||
run_id: str
|
||||
owner_id: str | None = None
|
||||
title: str
|
||||
question: str
|
||||
methodology_slug: str
|
||||
generated_at: datetime
|
||||
sections: list[ReportSection] = Field(default_factory=list)
|
||||
citations: list[ReportCitation] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _page_titles(evidence: EvidenceStore) -> dict[str, str]:
|
||||
return {page.url: page.title for page in evidence.pages()}
|
||||
|
||||
|
||||
def _clip(quote: str) -> str:
|
||||
return quote[:_QUOTE_MAX_CHARS]
|
||||
|
||||
|
||||
def build_evidence_artifact(*, run_id: str, evidence: EvidenceStore) -> EvidenceArtifact:
|
||||
"""Serialize the full EvidenceStore — ids renumbered ``pN``.
|
||||
|
||||
Every passage the run collected, full text included: this is the
|
||||
only persisted copy (the in-actor store dies at DONE).
|
||||
"""
|
||||
titles = _page_titles(evidence)
|
||||
passages = [
|
||||
EvidencePassage(
|
||||
passage_id=normalize_passage_id(passage.passage_id),
|
||||
url=passage.url,
|
||||
title=titles.get(passage.url, ""),
|
||||
quote=_clip(passage.quote),
|
||||
text=passage.text,
|
||||
)
|
||||
for passage in evidence.writer_context()
|
||||
]
|
||||
return EvidenceArtifact(run_id=run_id, passages=passages)
|
||||
|
||||
|
||||
def build_report_artifact(
|
||||
*,
|
||||
run_id: str,
|
||||
owner_id: str | None,
|
||||
question: str,
|
||||
methodology_name: str,
|
||||
methodology_slug: str,
|
||||
report_markdown: str,
|
||||
ledger: CitationLedger,
|
||||
evidence: EvidenceStore,
|
||||
generated_at: datetime,
|
||||
) -> ReportArtifact:
|
||||
"""Build report.json from the raw report.md + ledger + evidence.
|
||||
|
||||
Section bodies carry normalized ``[^pN]`` markers (C3-10); the raw
|
||||
``report.md`` on disk is untouched. Citations are the ledger's cited
|
||||
URL entries resolved to their stored passages — real quotes from
|
||||
the evidence in hand, never fabricated.
|
||||
"""
|
||||
title = extract_title(report_markdown, fallback=f"{methodology_name} — {question}")
|
||||
sections = [
|
||||
ReportSection(heading=heading, body=normalize_markers(body))
|
||||
for heading, body in split_sections(report_markdown)
|
||||
]
|
||||
titles = _page_titles(evidence)
|
||||
citations: list[ReportCitation] = []
|
||||
for entry in ledger.entries:
|
||||
for passage_id in entry.passages:
|
||||
passage = evidence.get(passage_id)
|
||||
if passage is None:
|
||||
continue
|
||||
citations.append(
|
||||
ReportCitation(
|
||||
passage_id=normalize_passage_id(passage_id),
|
||||
url=entry.url,
|
||||
title=titles.get(entry.url, "") or entry.url,
|
||||
quote=_clip(passage.quote),
|
||||
health=entry.health,
|
||||
authority_tier=entry.authority_tier,
|
||||
)
|
||||
)
|
||||
return ReportArtifact(
|
||||
run_id=run_id,
|
||||
owner_id=owner_id,
|
||||
title=title,
|
||||
question=question,
|
||||
methodology_slug=methodology_slug,
|
||||
generated_at=generated_at,
|
||||
sections=sections,
|
||||
citations=citations,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EvidenceArtifact",
|
||||
"EvidencePassage",
|
||||
"ReportArtifact",
|
||||
"ReportCitation",
|
||||
"ReportSection",
|
||||
"build_evidence_artifact",
|
||||
"build_report_artifact",
|
||||
"extract_title",
|
||||
"normalize_markers",
|
||||
"normalize_passage_id",
|
||||
"split_sections",
|
||||
]
|
||||
@@ -13,12 +13,14 @@ from researchx.methodologies import registry
|
||||
async def main() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
owner_id = "demo0001" # 8-char owner slug (P-9: demo passes the owner)
|
||||
run_id, config = make_run_config(
|
||||
question="How do verification pipelines keep citations honest?",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
plan = (
|
||||
'{"sub_questions": ["verification passes", "quote anchoring"], '
|
||||
@@ -36,7 +38,7 @@ async def main() -> None:
|
||||
]
|
||||
)
|
||||
search = FakeSearchAdapter(CORPUS)
|
||||
storage = LocalStorageAdapter(base_dir=base)
|
||||
storage = LocalStorageAdapter(base_dir=base, owner_id=owner_id)
|
||||
bus = EventBus()
|
||||
actor = RunActor(
|
||||
run_config=config, model=model, search=search, storage=storage, registry=registry,
|
||||
|
||||
@@ -18,6 +18,13 @@ class RunState(BaseModel):
|
||||
context: str = ""
|
||||
methodology_slug: str
|
||||
state: RunStateName = RunStateName.PLANNING
|
||||
# Owner of the run (REQ-71). None on pre-v0.3 state files — old
|
||||
# artifacts must deserialize unchanged (D-106: no migration).
|
||||
owner_id: str | None = None
|
||||
# Which credential tier resolved for this run (REQ-74: user|
|
||||
# server|keyless). None on pre-v0.3.2 state files — old artifacts
|
||||
# deserialize unchanged (D-106: no migration).
|
||||
credential_tier: str | None = None
|
||||
plan: Plan | None = None
|
||||
depth_cursor: int = 0
|
||||
depth_target: int = 1
|
||||
|
||||
@@ -15,7 +15,14 @@ class RunStateName(StrEnum):
|
||||
|
||||
|
||||
_LEGAL_TRANSITIONS: dict[RunStateName, set[RunStateName]] = {
|
||||
RunStateName.PLANNING: {RunStateName.AWAITING_APPROVAL, RunStateName.FAILED},
|
||||
# G-101: PLANNING -> CANCELLED is legal — the restart sweep rewrites
|
||||
# in-flight PLANNING runs as CANCELLED; semantically a run that has
|
||||
# not produced a plan can always be abandoned.
|
||||
RunStateName.PLANNING: {
|
||||
RunStateName.AWAITING_APPROVAL,
|
||||
RunStateName.CANCELLED,
|
||||
RunStateName.FAILED,
|
||||
},
|
||||
RunStateName.AWAITING_APPROVAL: {
|
||||
RunStateName.SEARCHING,
|
||||
RunStateName.CANCELLED,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Shared test fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_sse_starlette_loop_binding():
|
||||
"""Reset sse_starlette's module-global shutdown Event before each test.
|
||||
|
||||
``sse_starlette.AppStatus.should_exit_event`` is an ``anyio.Event``
|
||||
memoized at the first SSE response and bound to that event loop.
|
||||
pytest-asyncio gives every test a fresh loop, so a second SSE test in
|
||||
one process would die with "Event is bound to a different event
|
||||
loop". Resetting the global before each test keeps the library's
|
||||
loop-binding per-test. (Production is unaffected: single process,
|
||||
single loop, single uvicorn worker by design.)
|
||||
"""
|
||||
from sse_starlette.sse import AppStatus
|
||||
|
||||
AppStatus.should_exit = False
|
||||
AppStatus.should_exit_event = None
|
||||
yield
|
||||
@@ -137,6 +137,8 @@ async def test_attack_pause_resume_mid_depth_loop_over_http(tmp_path: Path) -> N
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(canned=canned),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
@@ -196,6 +198,8 @@ async def test_attack_cancel_mid_loop_persists(tmp_path: Path) -> None:
|
||||
+ [ChatResult(content="# never reached", model="fake-writer")]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
|
||||
@@ -15,6 +15,8 @@ _WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
|
||||
def _app(tmp_path: Path):
|
||||
# Zero-key dev harness (C3-16): auth explicitly off — the synthetic
|
||||
# dev admin resolves for every request.
|
||||
return create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
@@ -24,6 +26,8 @@ def _app(tmp_path: Path):
|
||||
]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Structured artifacts at DONE (T-3.1, D-113): report.json +
|
||||
evidence.json — marker normalization at the persistence boundary ONLY
|
||||
(C3-10: report.json bodies carry [^pN], report.md keeps raw [p-N])."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.engine.actor import EngineError, RunActor, make_run_config
|
||||
from researchx.engine.artifacts import (
|
||||
build_evidence_artifact,
|
||||
build_report_artifact,
|
||||
extract_title,
|
||||
normalize_markers,
|
||||
normalize_passage_id,
|
||||
split_sections,
|
||||
)
|
||||
from researchx.engine.events import EventBus
|
||||
from researchx.engine.states import RunStateName
|
||||
from researchx.evidence.store import EvidenceStore
|
||||
from researchx.methodologies import registry
|
||||
from researchx.trust.ledger import build_ledger
|
||||
from researchx.trust.liveness import LivenessStatus
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{
|
||||
"sub_questions": ["methodology verification", "quote anchoring"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro", "findings"],
|
||||
}
|
||||
)
|
||||
|
||||
# Multi-section report with [p-N] markers on multiple lines.
|
||||
_WRITER_REPORT = (
|
||||
"# Battery Advances\n\n"
|
||||
"The verification pass re-anchored every quoted claim [p-1].\n\n"
|
||||
"## Findings\n\n"
|
||||
"Methodology-first selection converts ad-hoc prompting [p-1] "
|
||||
"into reproducible decisions [p-2].\n\n"
|
||||
"## Conclusion\n\n"
|
||||
"The anchored loop holds [p-2].\n"
|
||||
)
|
||||
|
||||
|
||||
def _canned() -> list[ChatResult]:
|
||||
return [
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER_REPORT, model="fake-writer"),
|
||||
]
|
||||
|
||||
|
||||
def _storage(tmp_path: Path) -> LocalStorageAdapter:
|
||||
return LocalStorageAdapter(base_dir=tmp_path, owner_id="abcd2345")
|
||||
|
||||
|
||||
async def _run_to_done(tmp_path: Path) -> RunActor:
|
||||
_run_id, config = make_run_config(
|
||||
question="How do verification pipelines keep citations honest?",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
run_id="abcd2345",
|
||||
owner_id="abcd2345",
|
||||
)
|
||||
model = FakeModelAdapter(canned=_canned())
|
||||
search = FakeSearchAdapter(CORPUS)
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=model,
|
||||
search=search,
|
||||
storage=_storage(tmp_path),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
actor.state.owner_id = "abcd2345"
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state != RunStateName.AWAITING_APPROVAL:
|
||||
await asyncio.sleep(0.01)
|
||||
if task.done():
|
||||
break
|
||||
await actor.approve()
|
||||
await task
|
||||
assert actor.state.state == RunStateName.DONE
|
||||
return actor
|
||||
|
||||
|
||||
async def _read_json(tmp_path: Path, name: str) -> dict[str, object]:
|
||||
raw = await _storage(tmp_path).read_run_artifact("abcd2345", name)
|
||||
return json.loads(raw) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
# ------------------------------------------------------------- unit helpers
|
||||
|
||||
|
||||
def test_normalize_markers_converts_p_dash_form() -> None:
|
||||
assert normalize_markers("claim [p-1] and [p-12]") == "claim [^p1] and [^p12]"
|
||||
|
||||
|
||||
def test_normalize_passage_id_renumbers() -> None:
|
||||
assert normalize_passage_id("p-1") == "p1"
|
||||
assert normalize_passage_id("p-42") == "p42"
|
||||
|
||||
|
||||
def test_split_sections_on_headings() -> None:
|
||||
sections = split_sections("# Title\n\nintro body\n\n## A\n\na body\n\n### B\n\nb body")
|
||||
assert sections == [
|
||||
("Title", "intro body"),
|
||||
("A", "a body"),
|
||||
("B", "b body"),
|
||||
]
|
||||
|
||||
|
||||
def test_split_sections_preamble_and_no_headings() -> None:
|
||||
assert split_sections("loose text only") == [("", "loose text only")]
|
||||
assert split_sections("before\n\n# H\n\nafter") == [("", "before"), ("H", "after")]
|
||||
|
||||
|
||||
def test_extract_title_prefers_first_h1() -> None:
|
||||
assert extract_title("# First H1\n\n## Second\n", "fallback") == "First H1"
|
||||
assert extract_title("no heading at all", "Deep Report — q?") == "Deep Report — q?"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- build paths
|
||||
|
||||
|
||||
def _fixture_store() -> EvidenceStore:
|
||||
store = EvidenceStore()
|
||||
store.add_page(
|
||||
"https://example.org/verification",
|
||||
"Verification passes in research pipelines",
|
||||
(
|
||||
"The verification pass re-anchored every quoted claim to its stored "
|
||||
"passage before the report was allowed to ship, so the audit trail "
|
||||
"stays honest claim by claim."
|
||||
),
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
def test_build_evidence_artifact_renumbers_ids_and_keeps_text() -> None:
|
||||
artifact = build_evidence_artifact(run_id="abcd2345", evidence=_fixture_store())
|
||||
payload = json.loads(artifact.model_dump_json())
|
||||
assert payload["v"] == 1
|
||||
assert payload["run_id"] == "abcd2345"
|
||||
assert len(payload["passages"]) == 1
|
||||
passage = payload["passages"][0]
|
||||
assert passage["passage_id"] == "p1" # p-1 -> p1
|
||||
assert passage["url"] == "https://example.org/verification"
|
||||
assert passage["title"] == "Verification passes in research pipelines"
|
||||
assert passage["quote"] == passage["text"][:200]
|
||||
assert "re-anchored" in passage["text"]
|
||||
|
||||
|
||||
def test_build_report_artifact_sections_and_citations() -> None:
|
||||
store = _fixture_store()
|
||||
markdown = "# Report\n\nClaim [p-1] here.\n\n## Findings\n\nAnother [p-1] claim.\n"
|
||||
ledger = build_ledger(
|
||||
run_id="abcd2345",
|
||||
report_text=markdown,
|
||||
evidence=store,
|
||||
liveness={"https://example.org/verification": LivenessStatus.LIVE},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
artifact = build_report_artifact(
|
||||
run_id="abcd2345",
|
||||
owner_id="abcd2345",
|
||||
question="q?",
|
||||
methodology_name="Rapid Answer",
|
||||
methodology_slug="rapid-answer",
|
||||
report_markdown=markdown,
|
||||
ledger=ledger,
|
||||
evidence=store,
|
||||
generated_at=ledger.generated_at,
|
||||
)
|
||||
payload = json.loads(artifact.model_dump_json())
|
||||
assert payload["v"] == 1
|
||||
assert payload["owner_id"] == "abcd2345"
|
||||
assert payload["title"] == "Report"
|
||||
assert payload["question"] == "q?"
|
||||
assert payload["methodology_slug"] == "rapid-answer"
|
||||
assert [s["heading"] for s in payload["sections"]] == ["Report", "Findings"]
|
||||
# C3-10: normalized markers in section bodies ONLY.
|
||||
assert "[^p1]" in payload["sections"][0]["body"]
|
||||
assert "[p-1]" not in payload["sections"][0]["body"]
|
||||
assert [c["passage_id"] for c in payload["citations"]] == ["p1"]
|
||||
citation = payload["citations"][0]
|
||||
assert citation["url"] == "https://example.org/verification"
|
||||
assert citation["title"] == "Verification passes in research pipelines"
|
||||
assert citation["quote"].startswith("The verification pass")
|
||||
assert citation["health"] == "live"
|
||||
assert citation["authority_tier"] == "reference"
|
||||
|
||||
|
||||
def test_build_report_artifact_title_fallback_uses_methodology_name() -> None:
|
||||
store = _fixture_store()
|
||||
ledger = build_ledger(
|
||||
run_id="abcd2345",
|
||||
report_text="no heading [p-1]",
|
||||
evidence=store,
|
||||
liveness={},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
artifact = build_report_artifact(
|
||||
run_id="abcd2345",
|
||||
owner_id=None,
|
||||
question="what about X?",
|
||||
methodology_name="Deep Report",
|
||||
methodology_slug="deep-report",
|
||||
report_markdown="no heading [p-1]",
|
||||
ledger=ledger,
|
||||
evidence=store,
|
||||
generated_at=ledger.generated_at,
|
||||
)
|
||||
assert artifact.title == "Deep Report — what about X?"
|
||||
|
||||
|
||||
# --------------------------------------------------- at-DONE persistence
|
||||
|
||||
|
||||
async def test_done_writes_report_and_evidence_artifacts(tmp_path: Path) -> None:
|
||||
await _run_to_done(tmp_path)
|
||||
|
||||
report_payload = await _read_json(tmp_path, "report.json")
|
||||
evidence_payload = await _read_json(tmp_path, "evidence.json")
|
||||
|
||||
# report.json shape
|
||||
assert report_payload["v"] == 1
|
||||
assert report_payload["run_id"] == "abcd2345"
|
||||
assert report_payload["owner_id"] == "abcd2345"
|
||||
assert report_payload["title"] == "Battery Advances"
|
||||
assert report_payload["methodology_slug"] == "rapid-answer"
|
||||
assert "generated_at" in report_payload
|
||||
assert [s["heading"] for s in report_payload["sections"]] == [ # type: ignore[call-overload]
|
||||
"Battery Advances",
|
||||
"Findings",
|
||||
"Conclusion",
|
||||
]
|
||||
|
||||
# evidence.json shape
|
||||
assert evidence_payload["v"] == 1
|
||||
assert evidence_payload["run_id"] == "abcd2345"
|
||||
assert len(evidence_payload["passages"]) >= 2 # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_marker_normalization_at_boundary_only(tmp_path: Path) -> None:
|
||||
"""C3-10 pinned end to end: report.json bodies carry [^pN]; the raw
|
||||
report.md keeps [p-N] (the trust-loop regexes' form)."""
|
||||
await _run_to_done(tmp_path)
|
||||
|
||||
report_payload = await _read_json(tmp_path, "report.json")
|
||||
markdown = await _storage(tmp_path).read_run_artifact("abcd2345", "report.md")
|
||||
|
||||
sections = report_payload["sections"]
|
||||
assert isinstance(sections, list) and sections
|
||||
joined = "\n".join(str(s["body"]) for s in sections)
|
||||
assert "[^p1]" in joined
|
||||
assert "[p-1]" not in joined
|
||||
assert "[^p2]" in joined
|
||||
# The raw artifact is untouched — trust-loop regexes keep working.
|
||||
assert "[p-1]" in markdown
|
||||
assert "[^p1]" not in markdown
|
||||
|
||||
|
||||
async def test_citations_carry_real_quotes_and_pn_ids(tmp_path: Path) -> None:
|
||||
await _run_to_done(tmp_path)
|
||||
report_payload = await _read_json(tmp_path, "report.json")
|
||||
evidence_payload = await _read_json(tmp_path, "evidence.json")
|
||||
|
||||
citations = report_payload["citations"]
|
||||
assert isinstance(citations, list) and citations
|
||||
for citation in citations:
|
||||
assert re.fullmatch(r"^p\d+$", str(citation["passage_id"]))
|
||||
assert citation["url"].startswith("https://")
|
||||
assert citation["quote"], "citation quote must be a real stored quote"
|
||||
assert len(str(citation["quote"])) <= 200
|
||||
assert citation["health"] in {"live", "stale", "dead"}
|
||||
assert citation["authority_tier"]
|
||||
|
||||
# Evidence carries full texts: quote is the clipped head of text.
|
||||
passages = evidence_payload["passages"]
|
||||
assert isinstance(passages, list) and passages
|
||||
evidence_by_id = {str(p["passage_id"]): p for p in passages}
|
||||
for citation in citations:
|
||||
passage = evidence_by_id[str(citation["passage_id"])]
|
||||
assert str(passage["quote"]) == str(citation["quote"])
|
||||
assert len(str(passage["text"])) >= len(str(passage["quote"]))
|
||||
|
||||
|
||||
async def test_artifacts_only_written_at_done(tmp_path: Path) -> None:
|
||||
"""A run cancelled at AWAITING_APPROVAL leaves no structured
|
||||
artifacts — the write point is _verify_phase, nothing earlier."""
|
||||
_run_id, config = make_run_config(
|
||||
question="q",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
run_id="abcd2345",
|
||||
owner_id="abcd2345",
|
||||
)
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=FakeModelAdapter(canned=_canned()),
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=_storage(tmp_path),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state != RunStateName.AWAITING_APPROVAL:
|
||||
await asyncio.sleep(0.01)
|
||||
actor.cancel()
|
||||
await actor.approve()
|
||||
with contextlib.suppress(asyncio.CancelledError, EngineError):
|
||||
await task
|
||||
run_dir = tmp_path / "runs" / "abcd2345" / "abcd2345"
|
||||
assert not (run_dir / "report.json").exists()
|
||||
assert not (run_dir / "evidence.json").exists()
|
||||
@@ -0,0 +1,590 @@
|
||||
"""Auth route suite (T-1.9 backend half, REQ-69/70 — RESEARCH §11 list).
|
||||
|
||||
Full adversarial surface of the auth layer, run with
|
||||
``auth_enabled=True``: first-user-admin, registration closing, uniform
|
||||
login failures, session/logout semantics, CSRF enforcement, admin
|
||||
create/deactivate (deactivation kills sessions), and the
|
||||
no-password-hash-anywhere guarantee.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.api.app import create_app
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
_ADMIN_EMAIL = "admin@example.org"
|
||||
_ADMIN_PASSWORD = "correct-horse-battery"
|
||||
_SECOND_EMAIL = "second@example.org"
|
||||
_SECOND_PASSWORD = "another-long-password"
|
||||
|
||||
_HEADERS = {CSRF_HEADER: "1"}
|
||||
|
||||
|
||||
def _app(tmp_path: Path):
|
||||
return create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
async def _client(tmp_path: Path) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(tmp_path)),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
async def _register_first_admin(client: httpx.AsyncClient) -> httpx.Response:
|
||||
return await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
async def _login(client: httpx.AsyncClient, email: str, password: str) -> httpx.Response:
|
||||
return await client.post(
|
||||
"/api/auth/login", json={"email": email, "password": password}, headers=_HEADERS
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ register
|
||||
|
||||
|
||||
async def test_healthz_outside_api_is_public(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
async def test_first_register_creates_admin(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await _register_first_admin(client)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["role"] == "admin"
|
||||
assert body["email"] == _ADMIN_EMAIL
|
||||
assert body["active"] is True
|
||||
assert len(body["user_id"]) == 8
|
||||
assert "password_hash" not in body
|
||||
assert "password" not in body
|
||||
|
||||
|
||||
async def test_second_register_is_403(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
response = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "registration closed"
|
||||
|
||||
|
||||
async def test_register_rejects_short_password(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": "short"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert response.status_code == 422 # pydantic contract rejects < 12
|
||||
|
||||
|
||||
async def test_register_requires_csrf_header(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "missing request header"
|
||||
|
||||
|
||||
async def test_concurrent_register_yields_exactly_one_admin(tmp_path: Path) -> None:
|
||||
"""D-104 adversarial pin: a simultaneous register storm on a fresh
|
||||
store must produce exactly one 201/admin — the emptiness check +
|
||||
insert are atomic under the store lock (create_first_admin), so a
|
||||
raced duplicate cannot both win. Regression guard for the
|
||||
create_user-based route that let N concurrent registers through."""
|
||||
racers = [(f"racer{i}@example.org", f"racing-password-{i:02d}") for i in range(8)]
|
||||
async with await _client(tmp_path) as client:
|
||||
responses = await asyncio.gather(
|
||||
*(
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": email, "password": password},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
for email, password in racers
|
||||
)
|
||||
)
|
||||
winners = [(r, racer) for r, racer in zip(responses, racers, strict=True) if r.status_code == 201]
|
||||
losers = [r for r in responses if r.status_code == 403]
|
||||
assert len(winners) == 1, "exactly one register may win the race"
|
||||
assert len(losers) == 7
|
||||
assert winners[0][0].json()["role"] == "admin"
|
||||
|
||||
# The winner can log in; every losing racer has no account.
|
||||
_, (winner_email, winner_password) = winners[0]
|
||||
login = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": winner_email, "password": winner_password},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert login.status_code == 200
|
||||
for email, password in racers:
|
||||
if email == winner_email:
|
||||
continue
|
||||
denied = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert denied.status_code == 401, "losing racer must have no account"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- login
|
||||
|
||||
|
||||
async def test_login_sets_httponly_cookie(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
response = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["role"] == "admin"
|
||||
set_cookie = response.headers["set-cookie"]
|
||||
assert "researchx_session=" in set_cookie
|
||||
assert "httponly" in set_cookie.lower()
|
||||
assert "samesite=lax" in set_cookie.lower()
|
||||
assert "path=/" in set_cookie.lower()
|
||||
assert "domain=" not in set_cookie.lower().replace("samesite=lax", "")
|
||||
assert "secure" not in set_cookie.lower() # cookie_secure=False in tests
|
||||
|
||||
|
||||
async def test_login_wrong_password_uniform_401(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
for email, password in [
|
||||
(_ADMIN_EMAIL, "wrong-password-here"),
|
||||
("ghost@example.org", _ADMIN_PASSWORD),
|
||||
]:
|
||||
response = await _login(client, email, password)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "invalid email or password"
|
||||
|
||||
|
||||
async def test_login_token_rotation(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
first = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
first_cookie = first.headers["set-cookie"].split(";")[0]
|
||||
second = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
second_cookie = second.headers["set-cookie"].split(";")[0]
|
||||
assert first_cookie != second_cookie # fresh token every login
|
||||
|
||||
|
||||
# ----------------------------------------------------------- guard + CSRF
|
||||
|
||||
|
||||
async def test_unauthenticated_401_on_protected_routes(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
for method, path in [
|
||||
("GET", "/api/methodologies"),
|
||||
("GET", "/api/config"),
|
||||
("GET", "/api/runs/abcd2345"),
|
||||
("GET", "/api/runs/abcd2345/events"),
|
||||
("GET", "/api/runs/abcd2345/report"),
|
||||
("GET", "/api/admin/users"),
|
||||
]:
|
||||
response = await client.request(method, path)
|
||||
assert response.status_code == 401, f"{method} {path} leaked without auth"
|
||||
assert response.json()["detail"] == "authentication required"
|
||||
|
||||
|
||||
async def test_csrf_header_required_on_non_get(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
# Session cookie attached, but no CSRF header on a POST.
|
||||
response = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "missing request header"
|
||||
|
||||
|
||||
async def test_gets_exempt_from_csrf_header(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
assert (await client.get("/api/methodologies")).status_code == 200
|
||||
assert (await client.get("/api/auth/me")).status_code == 200
|
||||
|
||||
|
||||
# ----------------------------------------------------------- me + logout
|
||||
|
||||
|
||||
async def test_me_returns_current_user(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.get("/api/auth/me")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["email"] == _ADMIN_EMAIL
|
||||
assert "password_hash" not in body
|
||||
|
||||
|
||||
async def test_logout_revokes_session(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
assert (await client.get("/api/methodologies")).status_code == 200
|
||||
response = await client.post("/api/auth/logout", headers=_HEADERS)
|
||||
assert response.status_code == 200
|
||||
assert (await client.get("/api/methodologies")).status_code == 401
|
||||
|
||||
|
||||
async def test_deactivated_user_401(tmp_path: Path) -> None:
|
||||
app = _app(tmp_path)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
second_id = created.json()["user_id"]
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert (await second.get("/api/methodologies")).status_code == 200
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
deactivated = await admin.post(
|
||||
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
# The deactivated user's session was revoked server-side.
|
||||
response = await second.get("/api/methodologies")
|
||||
assert response.status_code == 401
|
||||
|
||||
# A fresh login against the deactivated account also 401s —
|
||||
# uniformly, without revealing the reason (no enumeration).
|
||||
login = await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert login.status_code == 401
|
||||
assert login.json()["detail"] == "invalid email or password"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- admin
|
||||
|
||||
|
||||
async def test_admin_list_users_no_hashes(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.get("/api/admin/users")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body) == 1
|
||||
raw = json.dumps(body)
|
||||
assert "password_hash" not in raw
|
||||
assert _ADMIN_PASSWORD not in raw
|
||||
assert "argon2" not in raw
|
||||
|
||||
|
||||
async def test_admin_create_user_then_user_login(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
assert body["role"] == "user"
|
||||
assert body["active"] is True
|
||||
|
||||
login = await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
assert login.status_code == 200
|
||||
assert login.json()["role"] == "user"
|
||||
|
||||
|
||||
async def test_admin_create_rejects_short_password(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": "short"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_admin_deactivate_revokes_sessions(tmp_path: Path) -> None:
|
||||
app = _app(tmp_path)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
second_id = created.json()["user_id"]
|
||||
|
||||
# A separate client (separate cookie jar) for the second user.
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert (await second.get("/api/methodologies")).status_code == 200
|
||||
|
||||
# Deactivation from the admin session kills the second session.
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as admin:
|
||||
await _register_first_admin(admin) # store shared; already admin
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
deactivated = await admin.post(
|
||||
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
assert deactivated.json()["active"] is False
|
||||
|
||||
response = await second.get("/api/methodologies")
|
||||
assert response.status_code == 401 # session revoked by deactivation
|
||||
|
||||
|
||||
async def test_non_admin_cannot_reach_admin_routes(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
response = await client.get("/api/admin/users")
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "admin role required"
|
||||
|
||||
|
||||
async def test_admin_deactivate_unknown_user_404(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.post("/api/admin/users/zzzz9999/deactivate", headers=_HEADERS)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ------------------------------------------------------ authenticated runs
|
||||
|
||||
|
||||
async def test_cross_owner_access_404_on_every_run_endpoint(tmp_path: Path) -> None:
|
||||
"""Two-user isolation (REQ-71): zero visibility across every run
|
||||
endpoint — cross-owner access is indistinguishable from a missing
|
||||
run (404, not 403: no existence leak)."""
|
||||
app = _app(tmp_path)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/runs",
|
||||
json={"question": "admin's private question", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
login = await _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
assert login.status_code == 200
|
||||
del login # ids don't matter here; counters do
|
||||
|
||||
for method, path in [
|
||||
("GET", f"/api/runs/{run_id}"),
|
||||
("POST", f"/api/runs/{run_id}/approve"),
|
||||
("POST", f"/api/runs/{run_id}/pause"),
|
||||
("POST", f"/api/runs/{run_id}/resume"),
|
||||
("POST", f"/api/runs/{run_id}/cancel"),
|
||||
("GET", f"/api/runs/{run_id}/events"),
|
||||
("GET", f"/api/runs/{run_id}/report"),
|
||||
("GET", f"/api/runs/{run_id}/ledger"),
|
||||
]:
|
||||
response = await second.request(method, path, headers=_HEADERS)
|
||||
assert response.status_code == 404, f"{method} {path} leaked across owners"
|
||||
|
||||
# The owner still sees their run — the 404s were authorization, not
|
||||
# the run vanishing.
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await admin.get(f"/api/runs/{run_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_owner_id_recorded_in_persisted_state(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
login = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
user_id = login.json()["user_id"]
|
||||
created = await client.post(
|
||||
"/api/runs",
|
||||
json={"question": "q", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
run_id = created.json()["id"]
|
||||
|
||||
state_path = tmp_path / "runs" / user_id / run_id / "state.json"
|
||||
for _ in range(200): # the actor persists asynchronously
|
||||
if state_path.exists():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert state_path.exists(), "storage layout must be runs/<user_id>/<run_id>/"
|
||||
state_text = state_path.read_text()
|
||||
state = json.loads(state_text)
|
||||
assert state["owner_id"] == user_id
|
||||
|
||||
|
||||
async def test_cost_counters_owner_keyed(tmp_path: Path) -> None:
|
||||
app = _app(tmp_path)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/runs",
|
||||
json={"question": "q", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
config = await admin.get("/api/config")
|
||||
counters = config.json()["cost_counters"]
|
||||
assert counters["runs_created"] == 1 # the admin's own view, owner-keyed
|
||||
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
created = await second.post(
|
||||
"/api/runs",
|
||||
json={"question": "q2", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
config = await second.get("/api/config")
|
||||
assert config.json()["cost_counters"] == {"runs_created": 1, "runs_completed": 0}
|
||||
|
||||
|
||||
async def test_authenticated_run_lifecycle(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await client.post(
|
||||
"/api/runs",
|
||||
json={"question": "How do engines run?", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
for _ in range(200):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "awaiting_approval":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
approved = await client.post(f"/api/runs/{run_id}/approve", headers=_HEADERS)
|
||||
assert approved.status_code == 200
|
||||
for _ in range(200):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "done":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
report = await client.get(f"/api/runs/{run_id}/report")
|
||||
assert report.status_code == 200
|
||||
assert "[p-1]" in report.json()["markdown"]
|
||||
|
||||
|
||||
async def test_no_password_hash_in_any_response(tmp_path: Path) -> None:
|
||||
"""Every response body of a full auth + run session, scanned."""
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
responses = [
|
||||
await client.get("/api/auth/me"),
|
||||
await client.get("/api/admin/users"),
|
||||
await client.get("/api/methodologies"),
|
||||
await client.get("/api/config"),
|
||||
await client.get("/api/runs/abcd2345"),
|
||||
]
|
||||
for response in responses:
|
||||
raw = response.text
|
||||
assert "$argon2" not in raw # no hash material, ever
|
||||
assert '"password_hash"' not in raw # no hash field, ever
|
||||
assert _ADMIN_PASSWORD not in raw
|
||||
assert _SECOND_PASSWORD not in raw
|
||||
@@ -46,7 +46,7 @@ def _actor(tmp_path: Path) -> tuple[RunActor, EventBus, FakeSearchAdapter]:
|
||||
)
|
||||
model = FakeModelAdapter(canned=_canned())
|
||||
search = FakeSearchAdapter(CORPUS)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
|
||||
bus = EventBus()
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
@@ -71,20 +71,20 @@ async def test_full_walk_reaches_done(tmp_path: Path) -> None:
|
||||
assert state.state == RunStateName.DONE
|
||||
assert state.plan is not None and len(state.plan.sub_questions) == 2
|
||||
|
||||
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
|
||||
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
|
||||
assert json.loads(saved)["state"] == "DONE"
|
||||
|
||||
report = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "report.md")
|
||||
report = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "report.md")
|
||||
assert "[p-1]" in report
|
||||
|
||||
ledger_raw = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
|
||||
ledger_raw = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact(
|
||||
"abcd2345", "ledger.json"
|
||||
)
|
||||
ledger = json.loads(ledger_raw)
|
||||
assert ledger["checklist"]["citations_checked"] >= 1
|
||||
assert ledger["checklist"]["liveness_ratio"] >= 0
|
||||
|
||||
checklist = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
|
||||
checklist = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact(
|
||||
"abcd2345", "checklist.md"
|
||||
)
|
||||
assert "Verification Checklist" in checklist
|
||||
@@ -121,7 +121,7 @@ async def test_cancel_persists_cancelled_state(tmp_path: Path) -> None:
|
||||
await actor.approve()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
|
||||
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
|
||||
assert json.loads(saved)["state"] == "CANCELLED"
|
||||
|
||||
|
||||
@@ -160,14 +160,14 @@ async def test_planner_non_json_fails_honestly(tmp_path: Path) -> None:
|
||||
run_config=config,
|
||||
model=model,
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
task = asyncio.create_task(actor.run())
|
||||
with pytest.raises(Exception, match="planner returned non-JSON plan"):
|
||||
await task
|
||||
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
|
||||
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
|
||||
assert json.loads(saved)["state"] == "FAILED"
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ async def test_registry_to_fake_model_to_snapshot_round_trip(tmp_path) -> None:
|
||||
)
|
||||
assert run_config.resolved_dials.breadth == 3
|
||||
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
|
||||
await storage.write_run_artifact(run_id, "run_config.json", run_config.model_dump_json())
|
||||
|
||||
restored = RunConfig.model_validate_json(
|
||||
@@ -70,9 +70,12 @@ def test_override_rejects_unknown_dial() -> None:
|
||||
|
||||
|
||||
def test_new_run_id_format() -> None:
|
||||
# P-14: one generator, one alphabet (config/ids.py) — the old
|
||||
# token_hex(4) duplicate is gone.
|
||||
run_id = new_run_id()
|
||||
assert len(run_id) == 8
|
||||
assert all(c in "0123456789abcdef" for c in run_id)
|
||||
alphabet = set("abcdefghjkmnpqrstuvwxyz23456789")
|
||||
assert all(c in alphabet for c in run_id)
|
||||
|
||||
|
||||
async def test_fake_search_feeds_evidence_shape() -> None:
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Key-store adversarial route suite (T-2.8 — security-engineer hat).
|
||||
|
||||
Route-level adversarial coverage beyond the unit suite in
|
||||
test_key_store_settings.py: corrupt/tampered envelopes surfacing
|
||||
through the API (typed error, honest degraded tier, never a silent
|
||||
drop and never a 500), raw-key non-leak across the FULL response
|
||||
surface of a session that touches the key store, and the create_run
|
||||
409 honesty on a corrupt envelope (never a wrong-key run, never a
|
||||
silent tier fall).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.model_factory import ModelAdapterFactory
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.api.app import create_app
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
from researchx.auth.key_store import KEK_NAME, KeyStore
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
_ADMIN_EMAIL = "admin@example.org"
|
||||
_ADMIN_PASSWORD = "correct-horse-battery"
|
||||
_SECOND_EMAIL = "second@example.org"
|
||||
_SECOND_PASSWORD = "second-long-password"
|
||||
|
||||
_HEADERS = {CSRF_HEADER: "1"}
|
||||
_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode()
|
||||
_USER_KEY = "adversarial-user-key-4242"
|
||||
|
||||
|
||||
def _app(tmp_path: Path, secrets_values: dict[str, str]):
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text(
|
||||
"".join(f"{k}={v}\n" for k, v in secrets_values.items()), encoding="utf-8"
|
||||
)
|
||||
secrets = EnvFileSecretsProvider(paths=[env_file])
|
||||
return create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
secrets_provider=secrets,
|
||||
model_factory=ModelAdapterFactory(
|
||||
secrets,
|
||||
KeyStore(secrets, tmp_path),
|
||||
cloud_base_url="http://127.0.0.1:1/v1", # hermetic: unroutable
|
||||
local_base_url="http://127.0.0.1:1/v1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _login_admin(client: httpx.AsyncClient) -> None:
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
async def _store_key(client: httpx.AsyncClient, key: str) -> None:
|
||||
response = await client.put(
|
||||
"/api/config/settings/model-key", json={"key": key}, headers=_HEADERS
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["configured"] is True
|
||||
|
||||
|
||||
def _corrupt_key_file(tmp_path: Path, user_id: str, content: str) -> None:
|
||||
path = tmp_path / "keys" / f"{user_id}.json"
|
||||
assert path.is_file(), "the key must be stored before corrupting it"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
async def _current_user_id(client: httpx.AsyncClient) -> str:
|
||||
me = await client.get("/api/auth/me")
|
||||
return me.json()["user_id"]
|
||||
|
||||
|
||||
async def _await_terminal_or_approval(client: httpx.AsyncClient, run_id: str) -> str:
|
||||
for _ in range(300):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status in ("done", "failed", "cancelled", "awaiting_approval"):
|
||||
return status
|
||||
await asyncio.sleep(0.01)
|
||||
return status
|
||||
|
||||
|
||||
# ------------------------------------------------- corrupt envelope at the API
|
||||
|
||||
|
||||
async def test_config_status_degrades_honestly_on_garbage_envelope(tmp_path: Path) -> None:
|
||||
"""Garbage keys/<uid>.json -> config status 200 with a DEGRADED
|
||||
tier (keyless/server) + the error surfaced in key_status.reason —
|
||||
typed error surfacing, never a silent tier drop, never a 500."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
await _store_key(client, _USER_KEY)
|
||||
user_id = await _current_user_id(client)
|
||||
_corrupt_key_file(tmp_path, user_id, "this is garbage, not json")
|
||||
|
||||
config = await client.get("/api/config")
|
||||
assert config.status_code == 200 # never a 500
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] in ("server", "keyless") # honest degradation
|
||||
assert body["key_store"] == "enabled"
|
||||
assert body["key_status"]["configured"] is False
|
||||
assert body["key_status"]["reason"] is not None
|
||||
assert "corrupt" in body["key_status"]["reason"].lower()
|
||||
|
||||
|
||||
async def test_config_status_degrades_honestly_on_tampered_envelope(tmp_path: Path) -> None:
|
||||
"""A tampered envelope -> same honest degradation + 'failed
|
||||
authentication' reason — never the decrypted key, never a silent
|
||||
'user' tier."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
await _store_key(client, _USER_KEY)
|
||||
user_id = await _current_user_id(client)
|
||||
original = json.loads((tmp_path / "keys" / f"{user_id}.json").read_text())
|
||||
# Flip ciphertext bytes: the envelope stays valid JSON but
|
||||
# decrypt must fail (InvalidTag -> typed 'tampered').
|
||||
ct = base64.urlsafe_b64decode(original["entries"]["OLLAMA_API_KEY"]["ct"] + "===")
|
||||
flipped = bytes([ct[0] ^ 0xFF]) + ct[1:]
|
||||
original["entries"]["OLLAMA_API_KEY"]["ct"] = (
|
||||
base64.urlsafe_b64encode(flipped).decode().rstrip("=")
|
||||
)
|
||||
_corrupt_key_file(tmp_path, user_id, json.dumps(original))
|
||||
|
||||
config = await client.get("/api/config")
|
||||
assert config.status_code == 200
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] in ("server", "keyless")
|
||||
assert "authentication" in body["key_status"]["reason"].lower()
|
||||
assert _USER_KEY not in config.text
|
||||
|
||||
|
||||
async def test_create_run_corrupt_envelope_is_honest_409(tmp_path: Path) -> None:
|
||||
"""create_run with a corrupt stored envelope -> 409 with the typed
|
||||
error — the run is NOT created (never a wrong-key run, never a
|
||||
silent tier fall)."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
await _store_key(client, _USER_KEY)
|
||||
user_id = await _current_user_id(client)
|
||||
_corrupt_key_file(tmp_path, user_id, "{corrupt")
|
||||
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
|
||||
)
|
||||
assert created.status_code == 409
|
||||
detail = created.json()["detail"].lower()
|
||||
assert "corrupt" in detail or "key" in detail
|
||||
|
||||
|
||||
async def test_model_key_put_overwrites_after_corruption(tmp_path: Path) -> None:
|
||||
"""Recovery path: a user whose envelope corrupted can PUT a fresh
|
||||
key (atomic overwrite) and return to the 'user' tier."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
await _store_key(client, _USER_KEY)
|
||||
user_id = await _current_user_id(client)
|
||||
_corrupt_key_file(tmp_path, user_id, "garbage")
|
||||
config = await client.get("/api/config")
|
||||
assert config.json()["resolved_tier"] != "user"
|
||||
|
||||
await _store_key(client, "fresh-key-value-9999")
|
||||
config = await client.get("/api/config")
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "user"
|
||||
assert body["key_status"]["masked"].endswith("9999")
|
||||
|
||||
|
||||
# ------------------------------------------------------------- leak scan
|
||||
|
||||
|
||||
async def test_no_raw_key_in_full_session_response_surface(tmp_path: Path) -> None:
|
||||
"""Every response of a full session that stores, reads, and runs
|
||||
with a user key — scanned for the raw key material (REQ-73)."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings/model-key",
|
||||
json={"key": _USER_KEY},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
responses: list[httpx.Response] = [put]
|
||||
for path in [
|
||||
"/api/config",
|
||||
"/api/config/settings",
|
||||
"/api/config/settings/model-key",
|
||||
"/api/auth/me",
|
||||
"/api/methodologies",
|
||||
"/api/admin/users",
|
||||
]:
|
||||
responses.append(await client.get(path))
|
||||
responses.append(
|
||||
await client.put(
|
||||
"/api/config/settings", json={"appearance": "dark"}, headers=_HEADERS
|
||||
)
|
||||
)
|
||||
|
||||
# A run executing under the user tier must not leak the key in
|
||||
# any run response either.
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
|
||||
)
|
||||
assert created.status_code == 201
|
||||
responses.append(created)
|
||||
run_id = created.json()["id"]
|
||||
status = await _await_terminal_or_approval(client, run_id)
|
||||
responses.append(await client.get(f"/api/runs/{run_id}"))
|
||||
if status == "awaiting_approval":
|
||||
responses.append(
|
||||
await client.post(f"/api/runs/{run_id}/approve", headers=_HEADERS)
|
||||
)
|
||||
|
||||
for response in responses:
|
||||
assert _USER_KEY not in response.text, "raw model key leaked in a response"
|
||||
# And never on disk in plaintext.
|
||||
for path in (tmp_path / "keys").iterdir():
|
||||
assert _USER_KEY not in path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------- AAD bind via the API
|
||||
|
||||
|
||||
async def test_second_user_cannot_shadow_first_users_key(tmp_path: Path) -> None:
|
||||
"""Two users, each with their own key: the second user's tier is
|
||||
'user' via THEIR key, and the first user's key never affects the
|
||||
second user's resolution (per-user AAD bind end to end)."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _login_admin(admin)
|
||||
await _store_key(admin, "first-user-key-aaaa")
|
||||
config = await admin.get("/api/config")
|
||||
assert config.json()["resolved_tier"] == "user"
|
||||
assert config.json()["key_status"]["masked"].endswith("aaaa")
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": _SECOND_EMAIL,
|
||||
"password": _SECOND_PASSWORD,
|
||||
"role": "user",
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
# Before storing a key: keyless (no server key configured).
|
||||
config = await second.get("/api/config")
|
||||
assert config.json()["resolved_tier"] == "keyless"
|
||||
assert "first-user-key" not in config.text
|
||||
|
||||
await _store_key(second, "second-user-key-bbbb")
|
||||
config = await second.get("/api/config")
|
||||
assert config.json()["resolved_tier"] == "user"
|
||||
assert config.json()["key_status"]["masked"].endswith("bbbb")
|
||||
assert "aaaa" not in config.text
|
||||
|
||||
|
||||
# ---------------------------------------------- settings store isolation
|
||||
|
||||
|
||||
async def test_settings_corruption_is_typed_not_crashing(tmp_path: Path) -> None:
|
||||
"""A corrupt settings file surfaces a typed SettingsStoreError —
|
||||
the store fails honestly rather than silently serving defaults
|
||||
over a corrupt payload."""
|
||||
from researchx.auth.settings import SettingsStore, SettingsStoreError
|
||||
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
user_id = await _current_user_id(client)
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir(parents=True, exist_ok=True)
|
||||
(settings_dir / f"{user_id}.json").write_text("{{not json", encoding="utf-8")
|
||||
|
||||
with pytest.raises(SettingsStoreError):
|
||||
await SettingsStore(tmp_path).get(user_id)
|
||||
|
||||
|
||||
def test_key_store_error_is_adapter_error() -> None:
|
||||
"""The typed key-store error IS an AdapterError — the factory and
|
||||
routes can catch one family (C3-06 contract)."""
|
||||
from researchx.adapters.types import AdapterError
|
||||
from researchx.auth.key_store import KeyStoreError
|
||||
|
||||
assert issubclass(KeyStoreError, AdapterError)
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Key store + settings store suite (T-2.2/T-2.3, REQ-72/73).
|
||||
|
||||
The adversarial core of the per-user key store: KEK decode rules
|
||||
(b64/hex/wrong-length/garbage), set/get round-trips, AAD binding
|
||||
(a copied envelope under another user FAILS — never a wrong-key leak),
|
||||
nonce uniqueness, masked-only status, disabled-honesty without a KEK,
|
||||
0600 file permissions, and the settings defaults/update round-trip.
|
||||
|
||||
The STEP-4 adversarial extension (corrupt-entry surfacing, concurrent
|
||||
set_key atomicity, opt-in keyed live tier) lives at the bottom of this
|
||||
file under the "T-2.8 adversarial extension" banner.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.auth.key_store import KEK_NAME, KEY_SCOPE, KeyStore, KeyStoreError, load_kek_bytes
|
||||
from researchx.auth.settings import SettingsStore, UserSettings
|
||||
|
||||
_KEK_RAW = os.urandom(32)
|
||||
_KEK_B64 = base64.urlsafe_b64encode(_KEK_RAW).decode()
|
||||
_KEK_HEX = _KEK_RAW.hex()
|
||||
_USER = "abcd2345"
|
||||
_OTHER_USER = "efgh6789"
|
||||
_RAW_KEY = "sk-live-key-abcd1234"
|
||||
|
||||
|
||||
def _secrets_with(values: dict[str, str], tmp_path: Path) -> EnvFileSecretsProvider:
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text("".join(f"{k}={v}\n" for k, v in values.items()), encoding="utf-8")
|
||||
return EnvFileSecretsProvider(paths=[env_file])
|
||||
|
||||
|
||||
def _enabled_store(tmp_path: Path) -> KeyStore:
|
||||
return KeyStore(_secrets_with({KEK_NAME: _KEK_B64}, tmp_path), tmp_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- KEK decode
|
||||
|
||||
|
||||
def test_kek_decode_b64(tmp_path: Path) -> None:
|
||||
secrets = _secrets_with({KEK_NAME: _KEK_B64}, tmp_path)
|
||||
assert load_kek_bytes(secrets) == _KEK_RAW
|
||||
|
||||
|
||||
def test_kek_decode_hex(tmp_path: Path) -> None:
|
||||
secrets = _secrets_with({KEK_NAME: _KEK_HEX}, tmp_path)
|
||||
assert load_kek_bytes(secrets) == _KEK_RAW
|
||||
|
||||
|
||||
def test_kek_wrong_length_raises(tmp_path: Path) -> None:
|
||||
short = base64.urlsafe_b64encode(b"only-16-bytes!!").decode()
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
load_kek_bytes(_secrets_with({KEK_NAME: short}, tmp_path))
|
||||
assert "32 bytes" in str(exc_info.value)
|
||||
assert exc_info.value.kind == "bad_kek"
|
||||
|
||||
|
||||
def test_kek_garbage_raises(tmp_path: Path) -> None:
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
load_kek_bytes(_secrets_with({KEK_NAME: "!!!not-base64!!!"}, tmp_path))
|
||||
assert exc_info.value.kind == "bad_kek"
|
||||
|
||||
|
||||
def test_kek_absent_is_none_not_error(tmp_path: Path) -> None:
|
||||
assert load_kek_bytes(_secrets_with({}, tmp_path)) is None
|
||||
|
||||
|
||||
def test_bad_kek_disables_store_honestly(tmp_path: Path) -> None:
|
||||
"""A malformed KEK is not a crash and not a silent enable."""
|
||||
store = KeyStore(_secrets_with({KEK_NAME: "garbage-value"}, tmp_path), tmp_path)
|
||||
assert store.enabled is False
|
||||
assert store.disabled_reason is not None
|
||||
assert "misconfigured" in store.disabled_reason
|
||||
|
||||
|
||||
# ------------------------------------------------------------- round trips
|
||||
|
||||
|
||||
async def test_set_get_round_trip(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
assert await store.get_key(_USER) == _RAW_KEY
|
||||
|
||||
|
||||
async def test_get_without_set_is_none(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
assert await store.get_key(_USER) is None
|
||||
|
||||
|
||||
async def test_set_overwrites_previous_key(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, "first-key-value")
|
||||
await store.set_key(_USER, KEY_SCOPE, "second-key-value")
|
||||
assert await store.get_key(_USER) == "second-key-value"
|
||||
|
||||
|
||||
async def test_envelope_shape_on_disk(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
|
||||
assert payload["v"] == 1
|
||||
assert payload["alg"] == "AES-256-GCM"
|
||||
assert "updated_at" in payload
|
||||
entry = payload["entries"][KEY_SCOPE]
|
||||
assert set(entry) == {"nonce", "ct", "updated_at"}
|
||||
# Raw key material must not appear anywhere in the file.
|
||||
assert _RAW_KEY not in json.dumps(payload)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- AAD
|
||||
|
||||
|
||||
async def test_aad_tamper_copy_to_other_user_fails(tmp_path: Path) -> None:
|
||||
"""The AAD is user_id: a file copied under another user's id FAILS
|
||||
decrypt with a typed error — never returns the other user's key."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
|
||||
keys_dir = tmp_path / "keys"
|
||||
(keys_dir / f"{_OTHER_USER}.json").write_text(
|
||||
(keys_dir / f"{_USER}.json").read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await store.get_key(_OTHER_USER)
|
||||
assert exc_info.value.kind == "tampered"
|
||||
# Never the wrong user's key:
|
||||
assert exc_info.value.args[0] != _RAW_KEY
|
||||
|
||||
|
||||
async def test_aad_tamper_masked_status_also_fails(tmp_path: Path) -> None:
|
||||
"""Status must not silently report configured=False for a tampered
|
||||
envelope — that would be a silent tier drop."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
keys_dir = tmp_path / "keys"
|
||||
(keys_dir / f"{_OTHER_USER}.json").write_text(
|
||||
(keys_dir / f"{_USER}.json").read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(KeyStoreError):
|
||||
await store.masked_status(_OTHER_USER)
|
||||
|
||||
|
||||
async def test_kek_change_surfaces_as_tamper(tmp_path: Path) -> None:
|
||||
"""A re-provisioned KEK cannot read old envelopes — typed error,
|
||||
never silent None (documented loss posture, G-105)."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
|
||||
new_kek = base64.urlsafe_b64encode(os.urandom(32)).decode()
|
||||
rotated = KeyStore(_secrets_with({KEK_NAME: new_kek}, tmp_path), tmp_path)
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await rotated.get_key(_USER)
|
||||
assert exc_info.value.kind == "tampered"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ nonces
|
||||
|
||||
|
||||
async def test_nonce_unique_across_writes(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
nonces = []
|
||||
for i in range(3):
|
||||
await store.set_key(_USER, KEY_SCOPE, f"key-number-{i}")
|
||||
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
|
||||
nonces.append(payload["entries"][KEY_SCOPE]["nonce"])
|
||||
assert len(set(nonces)) == 3 # a GCM nonce must NEVER repeat under a KEK
|
||||
|
||||
|
||||
async def test_nonce_is_12_bytes(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
|
||||
nonce_b64 = payload["entries"][KEY_SCOPE]["nonce"]
|
||||
assert len(base64.urlsafe_b64decode(nonce_b64 + "===")) == 12
|
||||
|
||||
|
||||
# ------------------------------------------------------------ masked status
|
||||
|
||||
|
||||
async def test_masked_status_never_returns_raw_key(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
status = await store.masked_status(_USER)
|
||||
assert status["configured"] is True
|
||||
assert status["masked"].endswith(_RAW_KEY[-4:])
|
||||
assert _RAW_KEY not in json.dumps(status)
|
||||
assert status["masked"].startswith("…"[:1]) or status["masked"][0] in ("…", ".")
|
||||
|
||||
|
||||
async def test_masked_status_unconfigured(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
status = await store.masked_status(_USER)
|
||||
assert status["configured"] is False
|
||||
assert status["masked"] is None
|
||||
|
||||
|
||||
# -------------------------------------------------------- disabled honesty
|
||||
|
||||
|
||||
async def test_disabled_store_set_rejected_honestly(tmp_path: Path) -> None:
|
||||
store = KeyStore(_secrets_with({}, tmp_path), tmp_path) # no KEK
|
||||
assert store.enabled is False
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
assert exc_info.value.kind == "disabled"
|
||||
assert "RESEARCHX_KEK" in str(exc_info.value)
|
||||
# And nothing was written to disk.
|
||||
assert not (tmp_path / "keys" / f"{_USER}.json").exists()
|
||||
|
||||
|
||||
async def test_disabled_store_get_returns_none(tmp_path: Path) -> None:
|
||||
store = KeyStore(_secrets_with({}, tmp_path), tmp_path)
|
||||
assert await store.get_key(_USER) is None
|
||||
|
||||
|
||||
async def test_disabled_store_status_says_disabled(tmp_path: Path) -> None:
|
||||
store = KeyStore(_secrets_with({}, tmp_path), tmp_path)
|
||||
status = await store.masked_status(_USER)
|
||||
assert status["disabled"] is True
|
||||
assert "RESEARCHX_KEK" in str(status["reason"])
|
||||
assert status["configured"] is False
|
||||
|
||||
|
||||
async def test_disabled_store_with_stored_key_surfaces_error(tmp_path: Path) -> None:
|
||||
"""The loss posture made visible: a KEK vanished after keys were
|
||||
stored — reads must surface an error state, never silently None
|
||||
(which would look like 'no key configured')."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
|
||||
gone = KeyStore(_secrets_with({}, tmp_path), tmp_path)
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await gone.get_key(_USER)
|
||||
assert exc_info.value.kind == "disabled"
|
||||
with pytest.raises(KeyStoreError):
|
||||
await gone.masked_status(_USER)
|
||||
|
||||
|
||||
async def test_unsupported_scope_rejected(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
with pytest.raises(KeyStoreError):
|
||||
await store.set_key(_USER, "TAVILY_API_KEY", "should-not-work")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ file perms
|
||||
|
||||
|
||||
async def test_key_file_perms_0600(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
mode = (tmp_path / "keys" / f"{_USER}.json").stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
def test_key_store_docstring_documents_loss_posture() -> None:
|
||||
"""G-105: the loss posture must be stated where the keys live."""
|
||||
import researchx.auth.key_store as key_store_module
|
||||
|
||||
doc = key_store_module.__doc__ or ""
|
||||
assert "KEK-loss" in doc or "unrecoverable" in doc
|
||||
assert "back up the KEK" in doc
|
||||
|
||||
|
||||
# ------------------------------------------------------------- settings
|
||||
|
||||
|
||||
async def test_settings_defaults_for_fresh_user(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
settings = await store.get(_USER)
|
||||
assert settings.planner_model
|
||||
assert settings.writer_model
|
||||
assert settings.methodology_defaults == {}
|
||||
assert settings.appearance == "system"
|
||||
# GET never writes:
|
||||
assert not (tmp_path / "settings" / f"{_USER}.json").exists()
|
||||
|
||||
|
||||
async def test_settings_update_round_trip(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
updated = await store.update(_USER, UserSettings(planner_model="m1", writer_model="m2"))
|
||||
assert updated.planner_model == "m1"
|
||||
assert updated.writer_model == "m2"
|
||||
assert updated.appearance == "system" # untouched field keeps default
|
||||
|
||||
reloaded = await store.get(_USER)
|
||||
assert reloaded.planner_model == "m1"
|
||||
assert reloaded.methodology_defaults == {}
|
||||
|
||||
|
||||
async def test_settings_partial_update_preserves_other_fields(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
await store.update(_USER, UserSettings(planner_model="keep-planner", appearance="dark"))
|
||||
updated = await store.update(_USER, UserSettings(appearance="light"))
|
||||
assert updated.planner_model == "keep-planner" # survived the partial write
|
||||
assert updated.appearance == "light"
|
||||
|
||||
|
||||
async def test_settings_methodology_defaults_round_trip(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
payload = UserSettings(methodology_defaults={"rapid-answer": {"depth": 2}})
|
||||
await store.update(_USER, payload)
|
||||
reloaded = await store.get(_USER)
|
||||
assert reloaded.methodology_defaults == {"rapid-answer": {"depth": 2}}
|
||||
|
||||
|
||||
async def test_settings_update_validates_merged_payload_before_write(tmp_path: Path) -> None:
|
||||
"""P2-verify P0 regression: the store never writes a payload it
|
||||
cannot read back — a null field that slipped past model_copy (which
|
||||
skips validation) is refused before touching disk."""
|
||||
store = SettingsStore(tmp_path)
|
||||
await store.update(_USER, UserSettings(planner_model="keep-me"))
|
||||
poisoned = await store.get(_USER)
|
||||
poisoned = poisoned.model_copy(update={"planner_model": None})
|
||||
with pytest.raises(ValueError):
|
||||
await store.update(_USER, poisoned)
|
||||
reloaded = await store.get(_USER)
|
||||
assert reloaded.planner_model == "keep-me" # on-disk payload untouched
|
||||
|
||||
|
||||
async def test_settings_isolated_per_user(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
await store.update(_USER, UserSettings(appearance="dark"))
|
||||
other = await store.get(_OTHER_USER)
|
||||
assert other.appearance == "system" # fresh defaults, no bleed
|
||||
assert not (tmp_path / "settings" / f"{_OTHER_USER}.json").exists()
|
||||
|
||||
|
||||
async def test_settings_corrupt_payload_surfaces_typed_error(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir(parents=True, exist_ok=True)
|
||||
(settings_dir / f"{_USER}.json").write_text("{not json", encoding="utf-8")
|
||||
from researchx.auth.settings import SettingsStoreError
|
||||
|
||||
with pytest.raises((SettingsStoreError, ValueError)):
|
||||
await store.get(_USER)
|
||||
|
||||
|
||||
# ================================================== T-2.8 adversarial extension
|
||||
# (security-engineer hat — corrupt-entry surfacing, concurrent-set
|
||||
# atomicity, and the opt-in keyed live tier. See STEP 4.)
|
||||
|
||||
|
||||
async def test_corrupt_envelope_surfaces_typed_error_never_silent_drop(tmp_path: Path) -> None:
|
||||
"""Garbage in keys/<uid>.json → KeyStoreError('unreadable'), never
|
||||
a silent None (which would silently drop the user's tier)."""
|
||||
store = _enabled_store(tmp_path)
|
||||
keys_dir = tmp_path / "keys"
|
||||
keys_dir.mkdir(parents=True, exist_ok=True)
|
||||
(keys_dir / f"{_USER}.json").write_text("this is not json at all", encoding="utf-8")
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await store.get_key(_USER)
|
||||
assert exc_info.value.kind == "unreadable"
|
||||
|
||||
|
||||
async def test_corrupt_entry_fields_surface_typed_error(tmp_path: Path) -> None:
|
||||
"""Structurally-valid JSON with missing/invalid fields — typed error."""
|
||||
store = _enabled_store(tmp_path)
|
||||
keys_dir = tmp_path / "keys"
|
||||
keys_dir.mkdir(parents=True, exist_ok=True)
|
||||
(keys_dir / f"{_USER}.json").write_text(
|
||||
json.dumps({"v": 1, "alg": "AES-256-GCM", "entries": {"OLLAMA_API_KEY": {"nonce": "xx"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(KeyStoreError):
|
||||
await store.get_key(_USER)
|
||||
|
||||
|
||||
async def test_bit_rot_ciphertext_surfaces_tamper(tmp_path: Path) -> None:
|
||||
"""Flipping ciphertext bytes → InvalidTag → typed 'tampered'."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
path = tmp_path / "keys" / f"{_USER}.json"
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
ct = base64.urlsafe_b64decode(payload["entries"][KEY_SCOPE]["ct"] + "===")
|
||||
flipped = bytes([ct[0] ^ 0xFF]) + ct[1:]
|
||||
payload["entries"][KEY_SCOPE]["ct"] = base64.urlsafe_b64encode(flipped).decode().rstrip("=")
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await store.get_key(_USER)
|
||||
assert exc_info.value.kind == "tampered"
|
||||
|
||||
|
||||
async def test_concurrent_set_key_never_tears_the_file(tmp_path: Path) -> None:
|
||||
"""Concurrent writes to the same user: every reader (including a
|
||||
second KeyStore handle over the same root) sees a valid envelope —
|
||||
the tmp+replace idiom leaves no torn files."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await asyncio.gather(
|
||||
*(store.set_key(_USER, KEY_SCOPE, f"concurrent-key-{i}") for i in range(16))
|
||||
)
|
||||
# The final file is one of the written keys, decryptable, well-formed.
|
||||
final = await store.get_key(_USER)
|
||||
assert final is not None
|
||||
assert final.startswith("concurrent-key-")
|
||||
|
||||
# A second handle (fresh process view) reads the same valid file.
|
||||
second = KeyStore(_secrets_with({KEK_NAME: _KEK_B64}, tmp_path), tmp_path)
|
||||
again = await second.get_key(_USER)
|
||||
assert again == final
|
||||
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
|
||||
assert payload["v"] == 1 # full envelope, never a partial write
|
||||
# No tmp strays left behind.
|
||||
assert [p.name for p in (tmp_path / "keys").iterdir() if p.name.endswith(".tmp")] == []
|
||||
|
||||
|
||||
async def test_set_key_atomic_under_concurrent_reads(tmp_path: Path) -> None:
|
||||
"""Reads racing a write never observe a torn file."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, "initial-key")
|
||||
|
||||
async def _reader() -> str | None:
|
||||
for _ in range(20):
|
||||
try:
|
||||
key = await store.get_key(_USER)
|
||||
if key is None:
|
||||
raise AssertionError("envelope vanished mid-race")
|
||||
except KeyStoreError as exc:
|
||||
raise AssertionError(f"torn read surfaced: {exc}") from exc
|
||||
return None
|
||||
|
||||
async def _writer() -> None:
|
||||
for i in range(10):
|
||||
await store.set_key(_USER, KEY_SCOPE, f"racing-key-{i}")
|
||||
|
||||
await asyncio.gather(_reader(), _writer())
|
||||
assert (await store.get_key(_USER)).startswith("racing-key-")
|
||||
|
||||
|
||||
def test_keyed_live_tier_marker_registered() -> None:
|
||||
"""The opt-in keyed live tier is marked `live` and skipped by
|
||||
default (same pattern as test_live_smoke.py): the marker must be
|
||||
registered in the pytest config so the opt-in test below skips
|
||||
cleanly in zero-key CI."""
|
||||
import tomllib
|
||||
|
||||
config = tomllib.loads((Path(__file__).resolve().parents[1] / "pyproject.toml").read_text())
|
||||
markers = config["tool"]["pytest"]["ini_options"]["markers"]
|
||||
assert any(str(m).startswith("live:") for m in markers)
|
||||
|
||||
|
||||
# ------------------------------------------------------- opt-in live tier
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
async def test_live_keyed_tier_end_to_end(tmp_path: Path) -> None:
|
||||
"""Opt-in keyed tier (skipped by default, like test_live_smoke):
|
||||
with a real RESEARCHX_KEK + OLLAMA_API_KEY in the secrets scope, a
|
||||
keyed user gets real inference through the REQ-74 chain."""
|
||||
secrets_paths = [
|
||||
Path.cwd() / ".ciagent" / ".env.secrets",
|
||||
Path.cwd().parents[1] / ".ciagent" / ".env.secrets",
|
||||
]
|
||||
env = EnvFileSecretsProvider(paths=secrets_paths)
|
||||
kek = env.resolve(KEK_NAME)
|
||||
model_key = env.resolve("OLLAMA_API_KEY")
|
||||
if not kek or not model_key:
|
||||
pytest.skip("RESEARCHX_KEK/OLLAMA_API_KEY not present — live tier is opt-in")
|
||||
|
||||
store = KeyStore(env, tmp_path)
|
||||
assert store.enabled, "with a configured KEK the store must be enabled"
|
||||
await store.set_key("live0001", KEY_SCOPE, model_key)
|
||||
resolved = await store.get_key("live0001")
|
||||
assert resolved == model_key
|
||||
@@ -0,0 +1,687 @@
|
||||
"""Live-surface route tests (T-3.3, REQ-76..82).
|
||||
|
||||
Every new/reworked route over a fixture disk tree: report/ledger
|
||||
disk-read with pre-artifact DONE fallback, library/connections/graph/
|
||||
tasks/suggestions, refine (200 on fakes, honest 503 on adapter
|
||||
failure), profile round-trip, and the FRESH-APP-OVER-EXISTING-DATA-
|
||||
ROOT test (P-8 full closure — every read route correct against a
|
||||
second create_app with zero in-memory actors).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
|
||||
from researchx.api.app import create_app
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Research Memo\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
_HEADERS = {CSRF_HEADER: "1"}
|
||||
|
||||
_DEV = "dev00000" # the synthetic dev admin (auth_enabled=False harness)
|
||||
|
||||
|
||||
def _state(run_id: str, question: str, state: str, created_at: str, plan: dict | None = None) -> dict:
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"question": question,
|
||||
"context": "",
|
||||
"methodology_slug": "deep-report",
|
||||
"state": state,
|
||||
"owner_id": _DEV,
|
||||
"plan": plan if plan is not None else {
|
||||
"sub_questions": ["battery density question"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro"],
|
||||
},
|
||||
"depth_cursor": 0,
|
||||
"depth_target": 1,
|
||||
"counters": {},
|
||||
"probe": None,
|
||||
"created_at": created_at,
|
||||
"updated_at": created_at,
|
||||
}
|
||||
|
||||
|
||||
def _seed_run(
|
||||
tmp_path: Path,
|
||||
run_id: str,
|
||||
*,
|
||||
state: str,
|
||||
question: str = "What about battery research?",
|
||||
created_at: str = "2026-09-20T10:00:00Z",
|
||||
plan: dict | None = None,
|
||||
report_markdown: str | None = None,
|
||||
report_json: dict | None = None,
|
||||
evidence_json: dict | None = None,
|
||||
ledger_json: dict | None = None,
|
||||
owner: str = _DEV,
|
||||
) -> None:
|
||||
run_dir = tmp_path / "runs" / owner / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
(run_dir / "state.json").write_text(
|
||||
json.dumps(_state(run_id, question, state, created_at, plan)), encoding="utf-8"
|
||||
)
|
||||
if report_markdown is not None:
|
||||
(run_dir / "report.md").write_text(report_markdown, encoding="utf-8")
|
||||
if report_json is not None:
|
||||
(run_dir / "report.json").write_text(json.dumps(report_json), encoding="utf-8")
|
||||
if evidence_json is not None:
|
||||
(run_dir / "evidence.json").write_text(json.dumps(evidence_json), encoding="utf-8")
|
||||
if ledger_json is not None:
|
||||
(run_dir / "ledger.json").write_text(json.dumps(ledger_json), encoding="utf-8")
|
||||
|
||||
|
||||
_REPORT_JSON = {
|
||||
"v": 1,
|
||||
"run_id": "runaaaa1",
|
||||
"owner_id": _DEV,
|
||||
"title": "Battery Advances",
|
||||
"question": "What about battery research?",
|
||||
"methodology_slug": "deep-report",
|
||||
"generated_at": "2026-09-20T10:05:00Z",
|
||||
"sections": [
|
||||
{"heading": "Research Memo", "body": "The verified core finding [^p1]."}
|
||||
],
|
||||
"citations": [
|
||||
{
|
||||
"passage_id": "p1",
|
||||
"url": "https://example.org/x",
|
||||
"title": "Verification passes",
|
||||
"quote": "The verified core finding.",
|
||||
"health": "live",
|
||||
"authority_tier": "reference",
|
||||
}
|
||||
],
|
||||
}
|
||||
_EVIDENCE_JSON = {
|
||||
"v": 1,
|
||||
"run_id": "runaaaa1",
|
||||
"passages": [
|
||||
{
|
||||
"passage_id": "p1",
|
||||
"url": "https://example.org/x",
|
||||
"title": "Verification passes",
|
||||
"quote": "The verified core finding.",
|
||||
"text": "The verified core finding in full, stored at DONE.",
|
||||
}
|
||||
],
|
||||
}
|
||||
_LEDGER_JSON = {
|
||||
"run_id": "runaaaa1",
|
||||
"generated_at": "2026-09-20T10:05:00Z",
|
||||
"entries": [
|
||||
{
|
||||
"url": "https://example.org/x",
|
||||
"visited_at": "2026-09-20T10:04:00Z",
|
||||
"passages": ["p-1"],
|
||||
"claims_supported": 2,
|
||||
"health": "live",
|
||||
"authority_tier": "reference",
|
||||
}
|
||||
],
|
||||
"checklist": {"run_id": "runaaaa1", "citations_checked": 1},
|
||||
}
|
||||
|
||||
|
||||
def _seed_tree(tmp_path: Path) -> Path:
|
||||
"""The route fixture tree under the dev owner:
|
||||
- runaaaa1: DONE with report.md + report.json + evidence.json + ledger
|
||||
- runbbbb1: DONE pre-artifact (report.md only — no report.json)
|
||||
- runcccc1: non-terminal (SEARCHING) — swept to CANCELLED by create_app
|
||||
"""
|
||||
_seed_run(
|
||||
tmp_path,
|
||||
"runaaaa1",
|
||||
state="DONE",
|
||||
report_markdown="# Research Memo\n\nThe verified core finding [p-1].\n",
|
||||
report_json=_REPORT_JSON,
|
||||
evidence_json=_EVIDENCE_JSON,
|
||||
ledger_json=_LEDGER_JSON,
|
||||
)
|
||||
_seed_run(
|
||||
tmp_path,
|
||||
"runbbbb1",
|
||||
state="DONE",
|
||||
question="A pre-artifact done run?",
|
||||
created_at="2026-09-19T10:00:00Z",
|
||||
report_markdown="# Old Report\n\nRaw [p-1] markers only.\n",
|
||||
)
|
||||
_seed_run(
|
||||
tmp_path,
|
||||
"runcccc1",
|
||||
state="SEARCHING",
|
||||
question="Swept mid-flight question?",
|
||||
created_at="2026-09-21T10:00:00Z",
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _fake_app(tmp_path: Path, canned: list[ChatResult] | None = None) -> object:
|
||||
return create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(canned=canned or [ChatResult(content=_PLAN, model="p")]),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
async def _client(app: object) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
|
||||
base_url="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- report route
|
||||
|
||||
|
||||
async def test_report_route_disk_read_returns_full_payload(tmp_path: Path) -> None:
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
response = await client.get("/api/runs/runaaaa1/report")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["run_id"] == "runaaaa1"
|
||||
assert "[p-1]" in body["markdown"] # raw form retained (back-compat)
|
||||
assert body["report"] == _REPORT_JSON
|
||||
assert body["evidence"] == _EVIDENCE_JSON
|
||||
|
||||
|
||||
async def test_report_route_pre_artifact_done_fallback(tmp_path: Path) -> None:
|
||||
"""A DONE run from before T-3.1 (report.md only): markdown served,
|
||||
null report/evidence — never a 500 (D-106)."""
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
response = await client.get("/api/runs/runbbbb1/report")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["run_id"] == "runbbbb1"
|
||||
assert "Raw [p-1] markers" in body["markdown"]
|
||||
assert body["report"] is None
|
||||
assert body["evidence"] is None
|
||||
|
||||
|
||||
async def test_report_route_missing_run_404(tmp_path: Path) -> None:
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
assert (await client.get("/api/runs/zzzz9999/report")).status_code == 404
|
||||
|
||||
|
||||
async def test_ledger_route_disk_read(tmp_path: Path) -> None:
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
response = await client.get("/api/runs/runaaaa1/ledger")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _LEDGER_JSON
|
||||
assert (await client.get("/api/runs/zzzz9999/ledger")).status_code == 404
|
||||
|
||||
|
||||
# ------------------------------------------------------------ run list route
|
||||
|
||||
|
||||
async def test_runs_route_lists_from_disk_recent_first(tmp_path: Path) -> None:
|
||||
"""GET /api/runs from disk state files; the SEARCHING fixture is
|
||||
swept to CANCELLED at create_app — it surfaces cancelled, NOT open
|
||||
(post-sweep honesty)."""
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
response = await client.get("/api/runs")
|
||||
assert response.status_code == 200
|
||||
runs = response.json()
|
||||
assert [run["id"] for run in runs] == ["runcccc1", "runaaaa1", "runbbbb1"]
|
||||
statuses = {run["id"]: run["status"] for run in runs}
|
||||
assert statuses["runcccc1"] == "cancelled" # swept, honest
|
||||
assert statuses["runaaaa1"] == "done"
|
||||
assert statuses["runbbbb1"] == "done"
|
||||
first = runs[1]
|
||||
assert first["question"] == "What about battery research?"
|
||||
assert first["methodology"] == "deep-report"
|
||||
assert first["plan"]["sub_questions"] == ["battery density question"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------- library routes
|
||||
|
||||
|
||||
async def test_library_route(tmp_path: Path) -> None:
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
response = await client.get("/api/library")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert [folder["id"] for folder in body["folders"]] == ["deep-report-battery"]
|
||||
assert [report["runId"] for report in body["reports"]] == ["runaaaa1", "runbbbb1"]
|
||||
first = body["reports"][0]
|
||||
assert first["title"] == "Battery Advances"
|
||||
assert first["citationCount"] == 1
|
||||
assert first["category"] == "deep-report-battery"
|
||||
# title sort: "Battery Advances" < "Deep Report — A pre-artifact..."
|
||||
titled = await client.get("/api/library?sort=title")
|
||||
assert [r["runId"] for r in titled.json()["reports"]] == ["runaaaa1", "runbbbb1"]
|
||||
|
||||
|
||||
async def test_connections_graph_tasks_suggestions_routes(tmp_path: Path) -> None:
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
connections = await client.get("/api/library/connections")
|
||||
assert connections.status_code == 200
|
||||
# runaaaa1 has a ledger; runbbbb1 has none -> no shared URL, and
|
||||
# same methodology+category -> related-topic edge.
|
||||
assert connections.json() == [
|
||||
{
|
||||
"fromReportId": "rep-runbbbb1",
|
||||
"toReportId": "rep-runaaaa1",
|
||||
"kind": "related-topic",
|
||||
"label": "Adjacent topic",
|
||||
}
|
||||
]
|
||||
|
||||
graph = await client.get("/api/graph")
|
||||
assert graph.status_code == 200
|
||||
payload = graph.json()
|
||||
assert {node["id"] for node in payload["nodes"]} == {"runaaaa1", "runbbbb1"}
|
||||
node = next(n for n in payload["nodes"] if n["id"] == "runaaaa1")
|
||||
assert node["topic"] == "Battery Advances"
|
||||
assert node["linkedSources"] == ["https://example.org/x"]
|
||||
assert payload["edges"] == [{"fromId": "runbbbb1", "toId": "runaaaa1", "sharedSourceCount": 0}]
|
||||
|
||||
tasks = await client.get("/api/tasks")
|
||||
assert tasks.status_code == 200
|
||||
items = {item["id"]: item for item in tasks.json()}
|
||||
assert items["runaaaa1#0"]["status"] == "done"
|
||||
assert items["runbbbb1#0"]["status"] == "done"
|
||||
# The swept CANCELLED run's question stays OPEN (D-114).
|
||||
assert items["runcccc1#0"]["status"] == "open"
|
||||
assert items["runcccc1#0"]["question"] == "battery density question"
|
||||
|
||||
suggestions = await client.get("/api/suggestions")
|
||||
assert suggestions.status_code == 200
|
||||
assert suggestions.json() == [
|
||||
{
|
||||
"id": "sug-runcccc1#0",
|
||||
"question": "battery density question",
|
||||
"sourceRunId": "runcccc1",
|
||||
"sourceReportTitle": "Deep Report — Swept mid-flight question?",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_fresh_user_empty_states(tmp_path: Path) -> None:
|
||||
"""Honest empty states: no synthetic content anywhere."""
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
assert (await client.get("/api/library")).json() == {"folders": [], "reports": []}
|
||||
assert (await client.get("/api/library/connections")).json() == []
|
||||
assert (await client.get("/api/graph")).json() == {"nodes": [], "edges": []}
|
||||
assert (await client.get("/api/tasks")).json() == []
|
||||
assert (await client.get("/api/suggestions")).json() == []
|
||||
assert (await client.get("/api/runs")).json() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- refine
|
||||
|
||||
|
||||
class _RefineModel:
|
||||
"""Fake model for the refine exchange: answers the questions prompt,
|
||||
then the compose prompt; a second instance answers with an
|
||||
AdapterError to pin the honest 503."""
|
||||
|
||||
def __init__(self, fail: bool = False) -> None:
|
||||
self._fail = fail
|
||||
self._calls = 0
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[ModelMessage],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.2,
|
||||
json_mode: bool = False,
|
||||
) -> ChatResult:
|
||||
del messages, temperature, model
|
||||
if self._fail:
|
||||
raise AdapterError("no local model reachable")
|
||||
self._calls += 1
|
||||
if self._calls == 1:
|
||||
return ChatResult(
|
||||
content=json.dumps(
|
||||
{
|
||||
"questions": [
|
||||
"What scope matters most?",
|
||||
"What decision will this inform?",
|
||||
"How deep should the answer go?",
|
||||
]
|
||||
}
|
||||
),
|
||||
model="fake-refine",
|
||||
)
|
||||
return ChatResult(
|
||||
content=json.dumps(
|
||||
{
|
||||
"question": "How do batteries work — focusing on density?",
|
||||
"context": "Feeds a supplier decision. Depth: executive summary.",
|
||||
}
|
||||
),
|
||||
model="fake-refine",
|
||||
)
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
return ["fake-refine"]
|
||||
|
||||
def roles(self) -> object:
|
||||
from researchx.adapters.model import ModelRoles
|
||||
|
||||
return ModelRoles(planner="fake-refine", writer="fake-refine")
|
||||
|
||||
|
||||
async def test_refine_questions_and_compose(tmp_path: Path) -> None:
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with await _client(app) as client:
|
||||
asked = await client.post("/api/refine/questions", json={"draft": "how do batteries work?"})
|
||||
assert asked.status_code == 200
|
||||
assert asked.json() == {
|
||||
"questions": [
|
||||
"What scope matters most?",
|
||||
"What decision will this inform?",
|
||||
"How deep should the answer go?",
|
||||
]
|
||||
}
|
||||
composed = await client.post(
|
||||
"/api/refine/compose",
|
||||
json={"draft": "how do batteries work?", "answers": ["density", "supplier choice"]},
|
||||
)
|
||||
assert composed.status_code == 200
|
||||
assert composed.json() == {
|
||||
"question": "How do batteries work — focusing on density?",
|
||||
"context": "Feeds a supplier decision. Depth: executive summary.",
|
||||
}
|
||||
|
||||
|
||||
async def test_refine_honest_503_never_hang(tmp_path: Path) -> None:
|
||||
"""A keyless user with no reachable endpoint gets an honest 503 —
|
||||
never a hang, never a silent success."""
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(fail=True),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with await _client(app) as client:
|
||||
asked = await asyncio.wait_for(
|
||||
client.post("/api/refine/questions", json={"draft": "q"}), timeout=5.0
|
||||
)
|
||||
assert asked.status_code == 503
|
||||
composed = await asyncio.wait_for(
|
||||
client.post(
|
||||
"/api/refine/compose", json={"draft": "q", "answers": ["a"]}, headers=_HEADERS
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
assert composed.status_code == 503
|
||||
|
||||
|
||||
async def test_refine_requires_csrf_header_when_authed(tmp_path: Path) -> None:
|
||||
"""Refine POSTs are mutations: the CSRF header applies (T-3.6)."""
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
# register + login (both with the header)
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "a@example.org", "password": "long-enough-pass", "display_name": "A"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await client.post(
|
||||
"/api/auth/login", json={"email": "a@example.org", "password": "long-enough-pass"}, headers=_HEADERS
|
||||
)
|
||||
no_header = await client.post("/api/refine/questions", json={"draft": "q"})
|
||||
assert no_header.status_code == 403
|
||||
with_header = await client.post("/api/refine/questions", json={"draft": "q"}, headers=_HEADERS)
|
||||
assert with_header.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- profile
|
||||
|
||||
|
||||
async def test_profile_round_trip(tmp_path: Path) -> None:
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "first@example.org", "password": "long-enough-pass", "display_name": "First"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "first@example.org", "password": "long-enough-pass"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
got = await client.get("/api/profile")
|
||||
assert got.status_code == 200
|
||||
assert got.json() == {
|
||||
"user_id": got.json()["user_id"],
|
||||
"display_name": "First",
|
||||
"email": "first@example.org",
|
||||
}
|
||||
put = await client.put(
|
||||
"/api/profile", json={"display_name": "Renamed", "email": "new@example.org"}, headers=_HEADERS
|
||||
)
|
||||
assert put.status_code == 200
|
||||
assert put.json()["display_name"] == "Renamed"
|
||||
assert put.json()["email"] == "new@example.org"
|
||||
again = await client.get("/api/profile")
|
||||
assert again.json() == put.json()
|
||||
|
||||
|
||||
async def test_profile_email_unique_across_users(tmp_path: Path) -> None:
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await admin.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "admin@example.org", "password": "long-enough-pass", "display_name": "A"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await admin.post(
|
||||
"/api/auth/login", json={"email": "admin@example.org", "password": "long-enough-pass"}, headers=_HEADERS
|
||||
)
|
||||
created = await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "second@example.org", "password": "long-enough-pass", "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
# admin tries to steal the second user's email
|
||||
taken = await admin.put(
|
||||
"/api/profile", json={"email": "second@example.org"}, headers=_HEADERS
|
||||
)
|
||||
assert taken.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------- cross-user isolation (authed)
|
||||
|
||||
|
||||
async def test_cross_user_404_on_run_scoped_routes(tmp_path: Path) -> None:
|
||||
"""Owner-scoped disk reads: user 2 requesting user 1's run gets a
|
||||
404 (no existence leak) — the owner dir simply doesn't exist under
|
||||
user 2's storage view."""
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await admin.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "admin@example.org", "password": "long-enough-pass", "display_name": "A"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await admin.post(
|
||||
"/api/auth/login", json={"email": "admin@example.org", "password": "long-enough-pass"}, headers=_HEADERS
|
||||
)
|
||||
admin_id = (await admin.get("/api/auth/me")).json()["user_id"]
|
||||
_seed_run(
|
||||
tmp_path,
|
||||
"runaaaa1",
|
||||
state="DONE",
|
||||
report_markdown="# Mine\n\n[p-1]\n",
|
||||
report_json=_REPORT_JSON,
|
||||
evidence_json=_EVIDENCE_JSON,
|
||||
ledger_json=_LEDGER_JSON,
|
||||
owner=admin_id,
|
||||
)
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "second@example.org", "password": "long-enough-pass", "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await second.post(
|
||||
"/api/auth/login", json={"email": "second@example.org", "password": "long-enough-pass"}, headers=_HEADERS
|
||||
)
|
||||
assert (await second.get("/api/runs/runaaaa1/report")).status_code == 404
|
||||
assert (await second.get("/api/runs/runaaaa1/ledger")).status_code == 404
|
||||
# The owner-scoped collections are simply empty for user 2.
|
||||
assert (await second.get("/api/runs")).json() == []
|
||||
assert (await second.get("/api/library")).json() == {"folders": [], "reports": []}
|
||||
assert (await second.get("/api/tasks")).json() == []
|
||||
assert (await second.get("/api/suggestions")).json() == []
|
||||
|
||||
|
||||
# ------------------------------------- P-8 full closure: fresh app, same root
|
||||
|
||||
|
||||
async def test_full_run_over_http_writes_and_serves_artifacts(tmp_path: Path) -> None:
|
||||
"""End-to-end marker contract through the API: a run driven to DONE
|
||||
persists report.json/evidence.json (T-3.1), and the reworked report
|
||||
route serves normalized [^pN] bodies while report.md (via the
|
||||
markdown field) keeps raw [p-N]."""
|
||||
app = _fake_app(
|
||||
tmp_path,
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
],
|
||||
)
|
||||
async with await _client(app) as client:
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "How do engines run?", "methodology": "rapid-answer"}
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
for _ in range(400): # await AWAITING_APPROVAL
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "awaiting_approval":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
await client.post(f"/api/runs/{run_id}/approve")
|
||||
for _ in range(400): # await DONE
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "done":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert status == "done"
|
||||
|
||||
report = (await client.get(f"/api/runs/{run_id}/report")).json()
|
||||
assert report["report"] is not None
|
||||
assert report["evidence"] is not None
|
||||
# C3-10 through the API: bodies normalized, markdown raw.
|
||||
joined = "\n".join(str(s["body"]) for s in report["report"]["sections"])
|
||||
assert "[^p1]" in joined or "[^p2]" in joined
|
||||
assert "[p-1]" not in joined
|
||||
assert "[p-1]" in report["markdown"]
|
||||
# citations + evidence ids are pN-form with real quotes.
|
||||
for citation in report["report"]["citations"]:
|
||||
assert citation["passage_id"].startswith("p")
|
||||
assert "-" not in citation["passage_id"]
|
||||
assert citation["quote"]
|
||||
passages = report["evidence"]["passages"]
|
||||
assert passages, "evidence must carry the full stored passages"
|
||||
assert all(len(str(p["text"])) >= len(str(p["quote"])) for p in passages)
|
||||
|
||||
# The disk read-models see the run too (library/tasks derive).
|
||||
library = (await client.get("/api/library")).json()
|
||||
assert [r["runId"] for r in library["reports"]] == [run_id]
|
||||
tasks = (await client.get("/api/tasks")).json()
|
||||
assert all(item["status"] == "done" for item in tasks)
|
||||
|
||||
|
||||
async def test_fresh_app_over_existing_data_root(tmp_path: Path) -> None:
|
||||
"""P-8 full closure: a SECOND create_app over the same data root —
|
||||
zero in-memory actors/registry — every read route still correct.
|
||||
(The registry-independence proof; the first app's actor state is
|
||||
unreachable by construction here.)"""
|
||||
_seed_tree(tmp_path)
|
||||
first = _fake_app(tmp_path)
|
||||
del first # the first app instance is discarded; nothing carries over
|
||||
|
||||
second = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(canned=[ChatResult(content=_PLAN, model="p")]),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with await _client(second) as client:
|
||||
# report: full payload, disk-read
|
||||
report = (await client.get("/api/runs/runaaaa1/report")).json()
|
||||
assert report["report"] == _REPORT_JSON
|
||||
assert report["evidence"] == _EVIDENCE_JSON
|
||||
assert "[p-1]" in report["markdown"]
|
||||
# pre-artifact fallback survives the restart too
|
||||
legacy = (await client.get("/api/runs/runbbbb1/report")).json()
|
||||
assert legacy["report"] is None and legacy["markdown"]
|
||||
# ledger
|
||||
assert (await client.get("/api/runs/runaaaa1/ledger")).json() == _LEDGER_JSON
|
||||
# run list: swept CANCELLED is cancelled, not open
|
||||
statuses = {run["id"]: run["status"] for run in (await client.get("/api/runs")).json()}
|
||||
assert statuses["runcccc1"] == "cancelled"
|
||||
# library + connections + graph + tasks + suggestions
|
||||
library = (await client.get("/api/library")).json()
|
||||
assert [r["runId"] for r in library["reports"]] == ["runaaaa1", "runbbbb1"]
|
||||
assert (await client.get("/api/library/connections")).status_code == 200
|
||||
assert (await client.get("/api/graph")).status_code == 200
|
||||
tasks = {item["id"]: item for item in (await client.get("/api/tasks")).json()}
|
||||
assert tasks["runcccc1#0"]["status"] == "open"
|
||||
assert (await client.get("/api/suggestions")).status_code == 200
|
||||
# profile (dev harness GET works with zero stored runs)
|
||||
assert (await client.get("/api/profile")).status_code == 200
|
||||
@@ -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
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Production entrypoint suite (T-2.7 — P-3 backend closure).
|
||||
|
||||
The live app built by ``researchx.api.main`` must be structurally
|
||||
incapable of serving fakes:
|
||||
|
||||
- The per-run model path is the ModelAdapterFactory — a run executes
|
||||
against an ``OllamaModelAdapter`` (resolved per user), never
|
||||
``FakeModelAdapter``. Pinned by asserting the factory resolves the
|
||||
adapter and that the app instance holds no fake in the model slot.
|
||||
- ``auth_enabled=True`` hardwired: every business route 401s without a
|
||||
session; there is no parameter or env that turns auth off.
|
||||
- ``cookie_secure=True``: the login cookie carries Secure (behind TLS).
|
||||
- The data root + secrets file come from env vars (RESEARCHX_DATA_DIR,
|
||||
RESEARCHX_SECRETS_FILE) — the test seam is the natural runtime
|
||||
config, parameterized not env-gated.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.fakes import FakeModelAdapter
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
|
||||
_HEADERS = {CSRF_HEADER: "1"}
|
||||
_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode()
|
||||
|
||||
_ADMIN_EMAIL = "admin@example.org"
|
||||
_ADMIN_PASSWORD = "correct-horse-battery"
|
||||
|
||||
|
||||
def _runtime_env(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, secrets: str | None, data_dir: Path
|
||||
) -> None:
|
||||
"""Point the production entry at test-owned locations.
|
||||
|
||||
This is the parameterization seam: main.py reads the same env vars
|
||||
the container runtime passes (RESEARCHX_SECRETS_FILE via --env-file
|
||||
conventions, RESEARCHX_DATA_DIR for the volume) — no test-only
|
||||
gates exist in the module.
|
||||
"""
|
||||
monkeypatch.setenv("RESEARCHX_DATA_DIR", str(data_dir))
|
||||
if secrets is None:
|
||||
monkeypatch.delenv("RESEARCHX_SECRETS_FILE", raising=False)
|
||||
else:
|
||||
env_file = tmp_path / "env.secrets"
|
||||
env_file.write_text(secrets, encoding="utf-8")
|
||||
monkeypatch.setenv("RESEARCHX_SECRETS_FILE", str(env_file))
|
||||
|
||||
|
||||
def _live_secrets(kek: bool = True, server_key: bool = True) -> str:
|
||||
lines = []
|
||||
if kek:
|
||||
lines.append(f"RESEARCHX_KEK={_KEK}")
|
||||
if server_key:
|
||||
lines.append("OLLAMA_API_KEY=server-tier-key-value")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
async def _register_admin(client: httpx.AsyncClient) -> None:
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
async def _login_with_secure_cookie(client: httpx.AsyncClient) -> str:
|
||||
"""Login and return the session cookie header value.
|
||||
|
||||
The production cookie carries Secure (hardwired, E-4); httpx's
|
||||
cookie jar — like a browser over plain http — will not replay a
|
||||
Secure cookie to a non-TLS origin. The tests therefore forward the
|
||||
cookie explicitly: the ASGI transport is the TLS proxy's interior
|
||||
leg, where the Secure flag does not apply.
|
||||
"""
|
||||
login = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert login.status_code == 200
|
||||
return login.headers["set-cookie"].split(";")[0]
|
||||
|
||||
|
||||
def _auth_headers(cookie: str) -> dict[str, str]:
|
||||
return {**_HEADERS, "cookie": cookie}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- structure
|
||||
|
||||
|
||||
async def test_main_factory_resolves_real_adapters(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The P-3 backend closure: main's factory wiring resolves a real
|
||||
OllamaModelAdapter per user — the production app can never serve
|
||||
FakeModelAdapter (no fixed model adapter is even constructed when
|
||||
a factory is wired; pinned structurally in app.py)."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=tmp_path / "data")
|
||||
main_module.build_app() # must import + build cleanly
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from researchx.adapters.model_factory import ModelAdapterFactory
|
||||
from researchx.adapters.ollama import OllamaModelAdapter
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.auth.key_store import KeyStore
|
||||
from researchx.auth.models import UserPublic
|
||||
from researchx.auth.settings import UserSettings
|
||||
|
||||
secrets_paths = main_module._secrets_paths()
|
||||
assert secrets_paths, "the runtime env file must be discoverable"
|
||||
secrets = EnvFileSecretsProvider(paths=secrets_paths)
|
||||
factory = ModelAdapterFactory(secrets, KeyStore(secrets, tmp_path / "data"))
|
||||
user = UserPublic(
|
||||
user_id="abcd2345",
|
||||
email="u@example.org",
|
||||
display_name="U",
|
||||
role="user",
|
||||
active=True,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
adapter, tier = await factory.resolve(user, UserSettings())
|
||||
assert isinstance(adapter, OllamaModelAdapter)
|
||||
assert not isinstance(adapter, FakeModelAdapter)
|
||||
assert tier in ("user", "server", "keyless")
|
||||
|
||||
|
||||
# --------------------------------------------------------------- behavior
|
||||
|
||||
|
||||
async def test_main_app_auth_hardwired(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No env, no parameter: every business route 401s without a
|
||||
session (REQ-85 — there is no auth_enabled=False path in main)."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=tmp_path / "data")
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
for path in [
|
||||
"/api/config",
|
||||
"/api/config/settings",
|
||||
"/api/config/settings/model-key",
|
||||
"/api/methodologies",
|
||||
"/api/runs/abcd2345",
|
||||
]:
|
||||
response = await client.get(path)
|
||||
assert response.status_code == 401, f"{path} leaked without auth"
|
||||
# POST-only surface rejects unauthenticated POSTs identically.
|
||||
assert (await client.post("/api/runs", json={}, headers=_HEADERS)).status_code == 401
|
||||
# The public surface stays public (D-103).
|
||||
assert (await client.get("/healthz")).status_code == 200
|
||||
|
||||
|
||||
async def test_main_app_cookie_secure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""cookie_secure=True hardwired (E-4): the session cookie carries
|
||||
Secure because the public surface is behind the TLS proxy."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _register_admin(client)
|
||||
login = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert login.status_code == 200
|
||||
set_cookie = login.headers["set-cookie"].lower()
|
||||
assert "secure" in set_cookie # hardwired behind TLS
|
||||
|
||||
|
||||
async def test_main_app_config_resolves_tier_with_runtime_env(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""End to end through the production wiring: register + login +
|
||||
GET /api/config shows the resolved tier and key-store state from
|
||||
the runtime env file (KEK + server key -> server tier for a
|
||||
keyless user, key_store enabled)."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _register_admin(client)
|
||||
cookie = await _login_with_secure_cookie(client)
|
||||
config = await client.get("/api/config", headers=_auth_headers(cookie))
|
||||
assert config.status_code == 200
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "server" # server key present, no user key
|
||||
assert body["key_store"] == "enabled"
|
||||
assert body["model_adapter"] == "OllamaModelAdapter" # never the fake
|
||||
|
||||
|
||||
async def test_main_app_keyless_without_keys(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""No KEK, no server key: key_store disabled, tier keyless — the
|
||||
honest zero-key posture through the production wiring."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
_runtime_env(tmp_path, monkeypatch, secrets="", data_dir=data_dir)
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _register_admin(client)
|
||||
cookie = await _login_with_secure_cookie(client)
|
||||
config = await client.get("/api/config", headers=_auth_headers(cookie))
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "keyless"
|
||||
assert body["key_store"] == "disabled"
|
||||
assert body["key_status"]["disabled"] is True
|
||||
|
||||
|
||||
async def test_main_app_key_store_round_trip_with_kek(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""With a runtime KEK the production app accepts a user key and
|
||||
resolves tier 'user' afterwards (REQ-73/74 chain end to end)."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _register_admin(client)
|
||||
cookie = await _login_with_secure_cookie(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings/model-key",
|
||||
json={"key": "user-tier-key-7777"},
|
||||
headers=_auth_headers(cookie),
|
||||
)
|
||||
assert put.status_code == 200
|
||||
assert put.json()["configured"] is True
|
||||
|
||||
config = await client.get("/api/config", headers=_auth_headers(cookie))
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "user"
|
||||
assert body["key_status"]["masked"].endswith("7777")
|
||||
|
||||
# The key file lands under the DATA ROOT (not cwd).
|
||||
me = await client.get("/api/auth/me", headers=_auth_headers(cookie))
|
||||
user_id = me.json()["user_id"]
|
||||
key_file = data_dir / "keys" / f"{user_id}.json"
|
||||
assert key_file.is_file()
|
||||
payload = json.loads(key_file.read_text(encoding="utf-8"))
|
||||
assert "user-tier-key-7777" not in json.dumps(payload) # encrypted at rest
|
||||
|
||||
|
||||
async def test_main_app_storage_is_data_root(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The config status storage_dir is the data-root string (D-107) —
|
||||
never cwd-dependent."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
data_dir = tmp_path / "data-root"
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _register_admin(client)
|
||||
cookie = await _login_with_secure_cookie(client)
|
||||
config = await client.get("/api/config", headers=_auth_headers(cookie))
|
||||
assert config.json()["storage_dir"] == str(data_dir)
|
||||
|
||||
|
||||
def test_main_module_has_no_auth_disable_surface() -> None:
|
||||
"""The main module exposes no auth_enabled parameter and reads no
|
||||
env var that could disable auth (REQ-85 + C3-16)."""
|
||||
import inspect
|
||||
|
||||
from researchx.api import main as main_module
|
||||
|
||||
signature = inspect.signature(main_module.build_app)
|
||||
assert "auth_enabled" not in signature.parameters
|
||||
assert "cookie_secure" not in signature.parameters
|
||||
source = inspect.getsource(main_module)
|
||||
assert "RESEARCHX_AUTH" not in source
|
||||
assert "auth_enabled=False" not in source
|
||||
# build_app passes auth_enabled=True exactly once (hardcoded).
|
||||
build_source = inspect.getsource(main_module.build_app)
|
||||
assert build_source.count("auth_enabled=") == 1
|
||||
assert "auth_enabled=True" in build_source
|
||||
assert "cookie_secure=True" in build_source
|
||||
@@ -59,7 +59,7 @@ async def test_evidence_reaches_writer_prompt(tmp_path: Path) -> None:
|
||||
run_config=config,
|
||||
model=model, # type: ignore[arg-type]
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
@@ -101,7 +101,7 @@ async def test_resume_from_searching_mid_run(tmp_path: Path) -> None:
|
||||
question="q", context="", methodology_slug="rapid-answer",
|
||||
overrides={}, registry=registry, run_id="abcd2345",
|
||||
)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
|
||||
mid_state = RunState(
|
||||
run_id="abcd2345",
|
||||
question="q",
|
||||
@@ -148,7 +148,7 @@ async def test_cancel_after_approval_persists_cancelled(tmp_path: Path) -> None:
|
||||
run_config=config,
|
||||
model=model,
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
@@ -161,7 +161,7 @@ async def test_cancel_after_approval_persists_cancelled(tmp_path: Path) -> None:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
saved = json.loads(
|
||||
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
|
||||
await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
|
||||
)
|
||||
assert saved["state"] == "CANCELLED"
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ async def test_probe_attaches_to_run_state(tmp_path: Path, monkeypatch) -> None:
|
||||
run_config=config,
|
||||
model=fake_model,
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
@@ -72,7 +72,7 @@ async def test_probe_attaches_to_run_state(tmp_path: Path, monkeypatch) -> None:
|
||||
state = await task
|
||||
assert state.state == RunStateName.DONE
|
||||
saved = json.loads(
|
||||
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
|
||||
await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
|
||||
)
|
||||
assert saved["probe"]["endpoint"] == "https://ollama.com/v1"
|
||||
|
||||
@@ -116,7 +116,7 @@ async def test_probe_failure_never_crashes_the_run(tmp_path: Path, monkeypatch)
|
||||
run_config=config,
|
||||
model=fake_model,
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
|
||||
registry=registry,
|
||||
event_bus=bus,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"""Read-model unit tests (T-3.2, D-114/D-115 — REQ-76..79).
|
||||
|
||||
A fixture disk tree — two users; DONE, CANCELLED, FAILED runs; a
|
||||
dotfile and a .tmp leftover — asserting categorization, sorting,
|
||||
shared-source/topic edges, tasks answered-iff DONE, suggestions
|
||||
(never synthetic, empty for fresh users), dotfile/tmp skip, and
|
||||
cross-user isolation at the read-model level.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.api.read_models import (
|
||||
categorize,
|
||||
connections,
|
||||
graph,
|
||||
library,
|
||||
list_runs,
|
||||
suggestions,
|
||||
tasks,
|
||||
topic_tokens,
|
||||
)
|
||||
|
||||
_U1 = "aaaa1111"
|
||||
_U2 = "bbbb2222"
|
||||
|
||||
_PLAN_A = {
|
||||
"sub_questions": ["battery energy density gains", "solid-state pilot production"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro"],
|
||||
}
|
||||
_PLAN_B = {
|
||||
"sub_questions": ["battery safety testing standards"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro"],
|
||||
}
|
||||
_PLAN_CANCELLED = {
|
||||
"sub_questions": ["abandoned open question"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro"],
|
||||
}
|
||||
_PLAN_FAILED = {"sub_questions": ["failed run question"], "source_universe": ["web"], "outline": []}
|
||||
_PLAN_INFLIGHT = {"sub_questions": ["still running question"], "source_universe": ["web"], "outline": []}
|
||||
|
||||
_REPORT_A = {
|
||||
"v": 1,
|
||||
"run_id": "runaaaa1",
|
||||
"owner_id": _U1,
|
||||
"title": "Battery Advances",
|
||||
"question": "What are the latest advances in solid-state batteries?",
|
||||
"methodology_slug": "deep-report",
|
||||
"generated_at": "2026-09-20T14:32:00Z",
|
||||
"sections": [{"heading": "Report", "body": "Claim [^p1]."}],
|
||||
"citations": [
|
||||
{"passage_id": "p1", "url": "https://example.org/x", "title": "X", "quote": "q", "health": "live", "authority_tier": "reference"}
|
||||
],
|
||||
}
|
||||
_LEDGER_A = {
|
||||
"run_id": "runaaaa1",
|
||||
"generated_at": "2026-09-20T14:32:00Z",
|
||||
"entries": [
|
||||
{"url": "https://example.org/x", "visited_at": "2026-09-20T14:00:00Z", "passages": ["p-1"], "claims_supported": 1, "health": "live", "authority_tier": "reference"},
|
||||
{"url": "https://example.org/y", "visited_at": "2026-09-20T14:00:00Z", "passages": ["p-2"], "claims_supported": 1, "health": "live", "authority_tier": "general"},
|
||||
],
|
||||
"checklist": {},
|
||||
}
|
||||
|
||||
_LEDGER_B = {
|
||||
"run_id": "runbbbb1",
|
||||
"generated_at": "2026-09-22T09:14:00Z",
|
||||
"entries": [
|
||||
{"url": "https://example.org/x", "visited_at": "2026-09-22T09:00:00Z", "passages": ["p-1"], "claims_supported": 2, "health": "live", "authority_tier": "reference"},
|
||||
{"url": "https://example.org/z", "visited_at": "2026-09-22T09:00:00Z", "passages": ["p-2"], "claims_supported": 1, "health": "stale", "authority_tier": "general"},
|
||||
],
|
||||
"checklist": {},
|
||||
}
|
||||
|
||||
|
||||
def _state(
|
||||
run_id: str,
|
||||
question: str,
|
||||
slug: str,
|
||||
state: str,
|
||||
plan: dict | None,
|
||||
created_at: str,
|
||||
) -> dict:
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"question": question,
|
||||
"context": "",
|
||||
"methodology_slug": slug,
|
||||
"state": state,
|
||||
"owner_id": _U1,
|
||||
"plan": plan,
|
||||
"depth_cursor": 0,
|
||||
"depth_target": 1,
|
||||
"counters": {},
|
||||
"probe": None,
|
||||
"created_at": created_at,
|
||||
"updated_at": created_at,
|
||||
}
|
||||
|
||||
|
||||
def _write_run(
|
||||
base: Path,
|
||||
owner: str,
|
||||
run_id: str,
|
||||
state: dict,
|
||||
report: dict | None = None,
|
||||
ledger: dict | None = None,
|
||||
) -> None:
|
||||
run_dir = base / "runs" / owner / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
(run_dir / "state.json").write_text(json.dumps(state), encoding="utf-8")
|
||||
if report is not None:
|
||||
(run_dir / "report.json").write_text(json.dumps(report), encoding="utf-8")
|
||||
if ledger is not None:
|
||||
(run_dir / "ledger.json").write_text(json.dumps(ledger), encoding="utf-8")
|
||||
|
||||
|
||||
def _tree(tmp_path: Path) -> Path:
|
||||
"""The fixture tree: user 1 has DONE A (deep-report, battery), DONE
|
||||
B (rapid-answer, battery safety), a CANCELLED run, and a FAILED
|
||||
run; user 2 has one DONE run invisible to user 1."""
|
||||
base = tmp_path
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runaaaa1",
|
||||
_state(
|
||||
"runaaaa1",
|
||||
"What are the latest advances in solid-state batteries?",
|
||||
"deep-report",
|
||||
"DONE",
|
||||
_PLAN_A,
|
||||
"2026-09-20T14:32:00Z",
|
||||
),
|
||||
report=_REPORT_A,
|
||||
ledger=_LEDGER_A,
|
||||
)
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runbbbb1",
|
||||
_state(
|
||||
"runbbbb1",
|
||||
"How do battery safety standards evolve?",
|
||||
"rapid-answer",
|
||||
"DONE",
|
||||
_PLAN_B,
|
||||
"2026-09-22T09:14:00Z",
|
||||
),
|
||||
ledger=_LEDGER_B,
|
||||
)
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runcanc1",
|
||||
_state(
|
||||
"runcanc1",
|
||||
"abandoned mid-flight",
|
||||
"deep-report",
|
||||
"CANCELLED",
|
||||
_PLAN_CANCELLED,
|
||||
"2026-09-21T10:00:00Z",
|
||||
),
|
||||
)
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runfail1",
|
||||
_state(
|
||||
"runfail1",
|
||||
"it broke",
|
||||
"rapid-answer",
|
||||
"FAILED",
|
||||
_PLAN_FAILED,
|
||||
"2026-09-23T10:00:00Z",
|
||||
),
|
||||
)
|
||||
# Defensive-scan fodder (D-115): dotfile + tmp leftovers + junk.
|
||||
(base / "runs" / _U1 / ".hidden").mkdir(parents=True)
|
||||
(base / "runs" / _U1 / "runbbbb1.tmp").mkdir(parents=True)
|
||||
(base / "runs" / _U1 / "orphan001").mkdir(parents=True) # no state.json: skipped
|
||||
# An in-flight run (raw, pre-sweep — the read-model sees the state
|
||||
# as written; the sweep happens at create_app in the route layer).
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runinflg1",
|
||||
_state(
|
||||
"runinflg1",
|
||||
"still running",
|
||||
"deep-report",
|
||||
"SEARCHING",
|
||||
_PLAN_INFLIGHT,
|
||||
"2026-09-25T08:00:00Z",
|
||||
),
|
||||
)
|
||||
# The other user's run: invisible at every read-model level.
|
||||
_write_run(
|
||||
base,
|
||||
_U2,
|
||||
"runcccc1",
|
||||
_state(
|
||||
"runcccc1",
|
||||
"user two's private research",
|
||||
"deep-report",
|
||||
"DONE",
|
||||
_PLAN_B,
|
||||
"2026-09-24T10:00:00Z",
|
||||
),
|
||||
ledger=_LEDGER_A,
|
||||
)
|
||||
return base
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ scanning
|
||||
|
||||
|
||||
def test_list_runs_scans_owner_partition_only(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
ids = [record.run_id for record in list_runs(base, _U1)]
|
||||
# CANCELLED + FAILED + in-flight included; dotfile/tmp/orphan
|
||||
# skipped; user 2's run absent.
|
||||
assert set(ids) == {"runaaaa1", "runbbbb1", "runcanc1", "runfail1", "runinflg1"}
|
||||
|
||||
|
||||
def test_list_runs_orders_most_recent_first(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
ids = [record.run_id for record in list_runs(base, _U1)]
|
||||
assert ids[0] == "runinflg1" # 2026-09-25
|
||||
assert ids[-1] == "runaaaa1" # 2026-09-20
|
||||
|
||||
|
||||
def test_list_runs_skips_dotfiles_and_tmp(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
ids = {record.run_id for record in list_runs(base, _U1)}
|
||||
assert ".hidden" not in ids
|
||||
assert "runbbbb1.tmp" not in ids
|
||||
|
||||
|
||||
def test_list_runs_empty_for_fresh_user(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
assert list_runs(base, "cccc3333") == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------ categorization
|
||||
|
||||
|
||||
def test_categorize_is_methodology_plus_first_topic_token() -> None:
|
||||
record = list_runs # placeholder to keep import honest
|
||||
del record
|
||||
from researchx.api.read_models import RunDiskRecord
|
||||
|
||||
run = RunDiskRecord(
|
||||
run_id="runaaaa1",
|
||||
state={
|
||||
"run_id": "runaaaa1",
|
||||
"question": "q",
|
||||
"methodology_slug": "deep-report",
|
||||
"state": "DONE",
|
||||
"plan": _PLAN_A,
|
||||
"created_at": "2026-09-20T14:32:00Z",
|
||||
},
|
||||
)
|
||||
assert categorize(run) == "deep-report-battery"
|
||||
|
||||
|
||||
def test_topic_tokens_skip_stopwords_and_dedupe() -> None:
|
||||
from researchx.api.read_models import RunDiskRecord
|
||||
|
||||
run = RunDiskRecord(
|
||||
run_id="r",
|
||||
state={
|
||||
"run_id": "r",
|
||||
"question": "q",
|
||||
"methodology_slug": "rapid-answer",
|
||||
"state": "DONE",
|
||||
"plan": {"sub_questions": ["The what of battery battery gains"]},
|
||||
"created_at": "2026-09-20T14:32:00Z",
|
||||
},
|
||||
)
|
||||
assert topic_tokens(run) == ["battery", "gains"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ library
|
||||
|
||||
|
||||
def test_library_folders_and_summaries(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
payload = library(base, _U1)
|
||||
# Folders from the two DONE runs' categories, sorted by id.
|
||||
assert [folder["id"] for folder in payload["folders"]] == [
|
||||
"deep-report-battery",
|
||||
"rapid-answer-battery",
|
||||
]
|
||||
assert payload["folders"][0]["name"] == "Deep Report: Battery"
|
||||
# Only DONE runs are filed (cancelled/failed produce nothing).
|
||||
assert {report["runId"] for report in payload["reports"]} == {"runaaaa1", "runbbbb1"}
|
||||
first = next(r for r in payload["reports"] if r["runId"] == "runaaaa1")
|
||||
assert first["id"] == "rep-runaaaa1"
|
||||
assert first["title"] == "Battery Advances"
|
||||
assert first["methodologySlug"] == "deep-report"
|
||||
assert first["category"] == "deep-report-battery"
|
||||
assert first["generatedAt"] == "2026-09-20T14:32:00Z"
|
||||
assert first["citationCount"] == 1
|
||||
# Pre-artifact DONE run: falls back to state created_at + methodology title.
|
||||
second = next(r for r in payload["reports"] if r["runId"] == "runbbbb1")
|
||||
assert second["generatedAt"] == "2026-09-22T09:14:00Z"
|
||||
assert second["title"] == "Rapid Answer — How do battery safety standards evolve?"
|
||||
|
||||
|
||||
def test_library_sort_recent_and_title(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
recent = [r["runId"] for r in library(base, _U1, sort="recent")["reports"]]
|
||||
title = [r["runId"] for r in library(base, _U1, sort="title")["reports"]]
|
||||
# recent: newest first (created_at desc).
|
||||
assert recent == ["runbbbb1", "runaaaa1"]
|
||||
# title: "Battery Advances" < "Rapid Answer — ..." A-Z.
|
||||
assert title == ["runaaaa1", "runbbbb1"]
|
||||
# unknown sort key falls back to recent.
|
||||
assert library(base, _U1, sort="bogus")["reports"][0]["runId"] == "runbbbb1"
|
||||
|
||||
|
||||
def test_library_fresh_user_is_empty(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
payload = library(base, "cccc3333")
|
||||
assert payload == {"folders": [], "reports": []}
|
||||
|
||||
|
||||
def test_library_cross_user_isolation(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
payload = library(base, _U1)
|
||||
assert all(report["runId"] != "runcccc1" for report in payload["reports"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------- connections
|
||||
|
||||
|
||||
def test_connections_shared_source_edge(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
edges = connections(base, _U1)
|
||||
shared = [e for e in edges if e["kind"] == "shared-sources"]
|
||||
# runaaaa1 + runbbbb1 share https://example.org/x (>=1 URL overlap).
|
||||
assert shared == [
|
||||
{
|
||||
"fromReportId": "rep-runaaaa1",
|
||||
"toReportId": "rep-runbbbb1",
|
||||
"kind": "shared-sources",
|
||||
"label": "1 shared source",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_connections_related_topic_edge_when_no_shared_urls(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
# Strip the shared URL: the two DONE runs then connect only via
|
||||
# category (same methodology + first topic token) — no, they have
|
||||
# DIFFERENT methodologies. Instead verify the pairing rule: with no
|
||||
# shared URL and differing categories there is NO edge.
|
||||
other = base / "runs" / _U1 / "runbbbb1" / "ledger.json"
|
||||
payload = json.loads(other.read_text(encoding="utf-8"))
|
||||
payload["entries"] = [e for e in payload["entries"] if e["url"] != "https://example.org/x"]
|
||||
other.write_text(json.dumps(payload), encoding="utf-8")
|
||||
edges = connections(base, _U1)
|
||||
# deep-report-battery vs rapid-answer-battery: different methodology
|
||||
# folders -> not related-topic, and no shared source -> no edge.
|
||||
assert edges == []
|
||||
|
||||
|
||||
def test_connections_related_topic_edge_on_same_category(tmp_path: Path) -> None:
|
||||
base = tmp_path / "iso"
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runx0011",
|
||||
_state("runx0011", "battery density", "deep-report", "DONE", _PLAN_A, "2026-09-20T10:00:00Z"),
|
||||
ledger={"entries": [{"url": "https://example.org/x"}], "checklist": {}},
|
||||
)
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runx0022",
|
||||
_state("runx0022", "battery pilots", "deep-report", "DONE", _PLAN_A, "2026-09-21T10:00:00Z"),
|
||||
ledger={"entries": [{"url": "https://example.org/q"}], "checklist": {}},
|
||||
)
|
||||
edges = connections(base, _U1)
|
||||
assert edges == [
|
||||
{
|
||||
"fromReportId": "rep-runx0011",
|
||||
"toReportId": "rep-runx0022",
|
||||
"kind": "related-topic",
|
||||
"label": "Adjacent topic",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_connections_cross_user_isolation(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
edges = connections(base, _U1)
|
||||
for edge in edges:
|
||||
assert "runcccc1" not in (edge["fromReportId"], edge["toReportId"])
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- graph
|
||||
|
||||
|
||||
def test_graph_nodes_and_edges(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
payload = graph(base, _U1)
|
||||
node_ids = {node["id"] for node in payload["nodes"]}
|
||||
assert node_ids == {"runaaaa1", "runbbbb1"} # DONE runs only
|
||||
node = next(n for n in payload["nodes"] if n["id"] == "runaaaa1")
|
||||
assert node["topic"] == "Battery Advances"
|
||||
assert set(node["linkedSources"]) == {"https://example.org/x", "https://example.org/y"}
|
||||
assert node["findings"] == ["Battery Advances"]
|
||||
assert payload["edges"] == [
|
||||
{"fromId": "runaaaa1", "toId": "runbbbb1", "sharedSourceCount": 1}
|
||||
]
|
||||
|
||||
|
||||
def test_graph_fresh_user_is_empty(tmp_path: Path) -> None:
|
||||
assert graph(_tree(tmp_path), "cccc3333") == {"nodes": [], "edges": []}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- tasks
|
||||
|
||||
|
||||
def test_tasks_answered_iff_done(tmp_path: Path) -> None:
|
||||
"""D-114: a sub-question is done IFF its run reached DONE; the
|
||||
post-sweep CANCELLED run's questions stay open."""
|
||||
base = _tree(tmp_path)
|
||||
items = tasks(base, _U1)
|
||||
by_id = {item["id"]: item for item in items}
|
||||
# DONE runs -> done items with <run_id>#<idx> ids.
|
||||
assert by_id["runaaaa1#0"] == {
|
||||
"id": "runaaaa1#0",
|
||||
"question": "battery energy density gains",
|
||||
"sourceRunId": "runaaaa1",
|
||||
"status": "done",
|
||||
}
|
||||
assert by_id["runbbbb1#0"]["status"] == "done"
|
||||
# CANCELLED + FAILED + in-flight runs -> open items.
|
||||
assert by_id["runcanc1#0"]["status"] == "open"
|
||||
assert by_id["runcanc1#0"]["question"] == "abandoned open question"
|
||||
assert by_id["runfail1#0"]["status"] == "open"
|
||||
assert by_id["runinflg1#0"]["status"] == "open"
|
||||
# user 2's runs absent.
|
||||
assert all("runcccc1" not in item["id"] for item in items)
|
||||
|
||||
|
||||
def test_tasks_cross_user_isolation(tmp_path: Path) -> None:
|
||||
items = tasks(_tree(tmp_path), "cccc3333")
|
||||
assert items == []
|
||||
|
||||
|
||||
# -------------------------------------------------------------- suggestions
|
||||
|
||||
|
||||
def test_suggestions_are_open_items_joined_with_titles(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
payload = suggestions(base, _U1)
|
||||
# Open items from the CANCELLED + FAILED + in-flight runs, most
|
||||
# recent run first, attributed to their source run titles; nothing
|
||||
# synthetic, nothing from DONE runs.
|
||||
assert payload == [
|
||||
{
|
||||
"id": "sug-runinflg1#0",
|
||||
"question": "still running question",
|
||||
"sourceRunId": "runinflg1",
|
||||
"sourceReportTitle": "Deep Report — still running",
|
||||
},
|
||||
{
|
||||
"id": "sug-runfail1#0",
|
||||
"question": "failed run question",
|
||||
"sourceRunId": "runfail1",
|
||||
"sourceReportTitle": "Rapid Answer — it broke",
|
||||
},
|
||||
{
|
||||
"id": "sug-runcanc1#0",
|
||||
"question": "abandoned open question",
|
||||
"sourceRunId": "runcanc1",
|
||||
"sourceReportTitle": "Deep Report — abandoned mid-flight",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_suggestions_fresh_user_empty_never_synthetic(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
assert suggestions(base, "cccc3333") == []
|
||||
|
||||
|
||||
def test_suggestions_exclude_done_runs(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
questions = [s["question"] for s in suggestions(base, _U1)]
|
||||
assert "battery energy density gains" not in questions # DONE run
|
||||
|
||||
|
||||
# ------------------------------------------------- defensive scanning extras
|
||||
|
||||
|
||||
def test_list_runs_skips_corrupt_state_json(tmp_path: Path) -> None:
|
||||
base = tmp_path
|
||||
run_dir = base / "runs" / _U1 / "corrupt01"
|
||||
run_dir.mkdir(parents=True)
|
||||
(run_dir / "state.json").write_text("{not json", encoding="utf-8")
|
||||
assert list_runs(base, _U1) == []
|
||||
@@ -40,7 +40,7 @@ async def test_state_file_round_trip_enables_restart(tmp_path: Path) -> None:
|
||||
registry=registry,
|
||||
run_id="abcd2345",
|
||||
)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
|
||||
state = RunState(
|
||||
run_id=run_id,
|
||||
question=config.question,
|
||||
@@ -68,7 +68,7 @@ async def test_actor_resumes_from_restored_state(tmp_path: Path) -> None:
|
||||
registry=registry,
|
||||
run_id="abcd2345",
|
||||
)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
|
||||
mid_state = RunState(
|
||||
run_id=run_id,
|
||||
question=config.question,
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
"""Settings + model-key routes + factory + config-status suite (STEP 2).
|
||||
|
||||
Covers T-2.4 (settings/key routes), T-2.5 (per-run model factory
|
||||
chain: user → server → keyless), and T-2.6 (config status rework —
|
||||
P-1 closure: factory-resolved probe for the CURRENT user, zero runs
|
||||
required, no cross-user leak).
|
||||
|
||||
Run under the ``auth_enabled=True`` harness (two users prove per-user
|
||||
isolation) plus direct factory tests against fake key stores.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.model_factory import ModelAdapterFactory
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.api.app import create_app
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
from researchx.auth.key_store import KEK_NAME, KeyStore
|
||||
from researchx.auth.models import UserPublic
|
||||
from researchx.auth.settings import UserSettings
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
_ADMIN_EMAIL = "admin@example.org"
|
||||
_ADMIN_PASSWORD = "correct-horse-battery"
|
||||
_SECOND_EMAIL = "second@example.org"
|
||||
_SECOND_PASSWORD = "another-long-password"
|
||||
|
||||
_HEADERS = {CSRF_HEADER: "1"}
|
||||
|
||||
_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode()
|
||||
|
||||
_FAKE_KEY = "sk-user-tier-key-9999"
|
||||
|
||||
|
||||
def _env_file(tmp_path: Path, values: dict[str, str]) -> Path:
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text("".join(f"{k}={v}\n" for k, v in values.items()), encoding="utf-8")
|
||||
return env_file
|
||||
|
||||
|
||||
def _user(user_id: str = "abcd2345") -> UserPublic:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
return UserPublic(
|
||||
user_id=user_id,
|
||||
email="user@example.org",
|
||||
display_name="User",
|
||||
role="user",
|
||||
active=True,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- harness
|
||||
|
||||
|
||||
def _app(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
secrets_values: dict[str, str] | None = None,
|
||||
with_factory: bool = True,
|
||||
):
|
||||
"""create_app under auth_enabled=True with live per-user stores.
|
||||
|
||||
The model adapter stays the zero-key fake (the fake's chat is what
|
||||
runs execute against); the FACTORY is wired so the tier chain and
|
||||
config-status rework are exercised end to end. The factory's bases
|
||||
point at an unroutable host so config-status probes fail fast and
|
||||
hermetically (no outbound HTTP from the unit tier — the probe
|
||||
contract has its own monkeypatched tests below).
|
||||
"""
|
||||
secrets = EnvFileSecretsProvider(
|
||||
paths=[_env_file(tmp_path, secrets_values or {})]
|
||||
)
|
||||
return create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
secrets_provider=secrets,
|
||||
model_factory=ModelAdapterFactory(
|
||||
secrets,
|
||||
KeyStore(secrets, tmp_path),
|
||||
cloud_base_url="http://127.0.0.1:1/v1",
|
||||
local_base_url="http://127.0.0.1:1/v1",
|
||||
)
|
||||
if with_factory
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
async def _client(tmp_path: Path, **kwargs) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(tmp_path, **kwargs)),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
async def _register_and_login(client: httpx.AsyncClient) -> dict[str, str]:
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
login = await client.post(
|
||||
"/api/auth/login", json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD}, headers=_HEADERS
|
||||
)
|
||||
return login.json()
|
||||
|
||||
|
||||
# --------------------------------------------------- settings CRUD routes
|
||||
|
||||
|
||||
async def test_settings_get_returns_defaults(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
me = await _register_and_login(client)
|
||||
response = await client.get("/api/config/settings")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["planner_model"]
|
||||
assert body["writer_model"]
|
||||
assert body["methodology_defaults"] == {}
|
||||
assert body["appearance"] == "system"
|
||||
# never leaks the user's id/email into the settings payload
|
||||
assert "user_id" not in body
|
||||
assert me["email"] not in json.dumps(body)
|
||||
|
||||
|
||||
async def test_settings_put_round_trip(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings",
|
||||
json={"planner_model": "qwen-max", "appearance": "dark"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert put.status_code == 200
|
||||
assert put.json()["planner_model"] == "qwen-max"
|
||||
assert put.json()["appearance"] == "dark"
|
||||
# writer_model untouched -> default preserved
|
||||
assert put.json()["writer_model"]
|
||||
|
||||
got = await client.get("/api/config/settings")
|
||||
assert got.json()["planner_model"] == "qwen-max"
|
||||
assert got.json()["appearance"] == "dark"
|
||||
|
||||
|
||||
async def test_settings_put_partial_preserves(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
await client.put(
|
||||
"/api/config/settings", json={"planner_model": "p-one"}, headers=_HEADERS
|
||||
)
|
||||
put = await client.put(
|
||||
"/api/config/settings", json={"writer_model": "w-two"}, headers=_HEADERS
|
||||
)
|
||||
assert put.json()["planner_model"] == "p-one"
|
||||
assert put.json()["writer_model"] == "w-two"
|
||||
|
||||
|
||||
async def test_settings_put_validates_body(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
response = await client.put(
|
||||
"/api/config/settings", json={"planner_model": ""}, headers=_HEADERS
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_settings_put_requires_csrf(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
response = await client.put("/api/config/settings", json={"appearance": "dark"})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_settings_put_explicit_null_treated_as_absent(tmp_path: Path) -> None:
|
||||
"""P2-verify P0 regression: an explicit null field must be treated
|
||||
as absent (partial-merge semantics) — never 200-then-poison the
|
||||
on-disk payload into a corrupt file that 500s every later read."""
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings", json={"planner_model": None}, headers=_HEADERS
|
||||
)
|
||||
assert put.status_code == 200
|
||||
body = put.json()
|
||||
assert body["planner_model"] # default preserved, not null
|
||||
|
||||
# Every subsequent read + write + run creation stays healthy.
|
||||
got = await client.get("/api/config/settings")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["planner_model"]
|
||||
again = await client.put(
|
||||
"/api/config/settings", json={"planner_model": "qwen-max"}, headers=_HEADERS
|
||||
)
|
||||
assert again.status_code == 200
|
||||
assert again.json()["planner_model"] == "qwen-max"
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
|
||||
async def test_settings_isolated_per_user(tmp_path: Path) -> None:
|
||||
app = _app(tmp_path)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_and_login(admin)
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await admin.put(
|
||||
"/api/config/settings", json={"appearance": "dark"}, headers=_HEADERS
|
||||
)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
got = await second.get("/api/config/settings")
|
||||
assert got.json()["appearance"] == "system" # no bleed
|
||||
|
||||
|
||||
# --------------------------------------------------- model-key routes
|
||||
|
||||
|
||||
async def test_model_key_put_masked_round_trip(tmp_path: Path) -> None:
|
||||
"""PUT the raw key -> 200 with masked status only; the raw key
|
||||
NEVER appears in any response (REQ-73)."""
|
||||
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
|
||||
me = await _register_and_login(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings/model-key",
|
||||
json={"key": _FAKE_KEY},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert put.status_code == 200
|
||||
body = put.json()
|
||||
assert body["configured"] is True
|
||||
assert body["masked"] is not None
|
||||
assert body["masked"].endswith(_FAKE_KEY[-4:])
|
||||
assert _FAKE_KEY not in put.text
|
||||
|
||||
status = await client.get("/api/config/settings/model-key")
|
||||
assert status.json()["configured"] is True
|
||||
assert _FAKE_KEY not in status.text
|
||||
assert me["user_id"] not in status.text
|
||||
|
||||
|
||||
async def test_model_key_put_disabled_without_kek_honest_409(tmp_path: Path) -> None:
|
||||
"""No KEK -> 409 with the honest {key_store: disabled} payload —
|
||||
never a silent acceptance."""
|
||||
async with await _client(tmp_path) as client: # no KEK in secrets
|
||||
await _register_and_login(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings/model-key",
|
||||
json={"key": "some-key-value"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert put.status_code == 409
|
||||
detail = put.json()["detail"]
|
||||
assert detail["key_store"] == "disabled"
|
||||
assert "RESEARCHX_KEK" in detail["reason"]
|
||||
# Nothing was written to disk.
|
||||
user_dir = tmp_path / "keys"
|
||||
assert not user_dir.exists() or not any(user_dir.iterdir())
|
||||
|
||||
|
||||
async def test_model_key_get_status_disabled_says_so(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
status = await client.get("/api/config/settings/model-key")
|
||||
assert status.status_code == 200
|
||||
body = status.json()
|
||||
assert body["disabled"] is True
|
||||
assert "RESEARCHX_KEK" in body["reason"]
|
||||
assert body["configured"] is False
|
||||
|
||||
|
||||
async def test_model_key_put_requires_csrf(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
|
||||
await _register_and_login(client)
|
||||
response = await client.put(
|
||||
"/api/config/settings/model-key", json={"key": "no-header-key"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_model_key_never_in_config_or_settings_responses(tmp_path: Path) -> None:
|
||||
"""The raw key must not surface in ANY authenticated response."""
|
||||
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
|
||||
await _register_and_login(client)
|
||||
await client.put(
|
||||
"/api/config/settings/model-key", json={"key": _FAKE_KEY}, headers=_HEADERS
|
||||
)
|
||||
for path in [
|
||||
"/api/config",
|
||||
"/api/config/settings",
|
||||
"/api/config/settings/model-key",
|
||||
"/api/auth/me",
|
||||
"/api/methodologies",
|
||||
]:
|
||||
response = await client.get(path)
|
||||
assert _FAKE_KEY not in response.text, f"raw key leaked via {path}"
|
||||
|
||||
|
||||
# --------------------------------------------------------- factory chain
|
||||
|
||||
|
||||
class _FakeKeyStore:
|
||||
"""Test double for KeyStore: enabled with a known key or disabled."""
|
||||
|
||||
def __init__(self, key: str | None = None, enabled: bool = True) -> None:
|
||||
self._key = key
|
||||
self._enabled_flag = enabled
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled_flag
|
||||
|
||||
async def get_key(self, user_id: str) -> str | None:
|
||||
del user_id
|
||||
return self._key
|
||||
|
||||
async def masked_status(self, user_id: str) -> dict[str, object]:
|
||||
del user_id
|
||||
return {
|
||||
"configured": self._key is not None,
|
||||
"masked": f"…{self._key[-4:]}" if self._key else None,
|
||||
"disabled": not self._enabled_flag,
|
||||
}
|
||||
|
||||
|
||||
def _factory(tmp_path: Path, secrets_values: dict[str, str], key_store: object) -> ModelAdapterFactory:
|
||||
secrets = EnvFileSecretsProvider(paths=[_env_file(tmp_path, secrets_values)])
|
||||
return ModelAdapterFactory(
|
||||
secrets,
|
||||
key_store, # type: ignore[arg-type]
|
||||
cloud_base_url="http://127.0.0.1:1/v1", # unroutable: hermetic unit tier
|
||||
local_base_url="http://127.0.0.1:1/v1",
|
||||
)
|
||||
|
||||
|
||||
async def test_factory_user_tier_when_key_present(tmp_path: Path) -> None:
|
||||
factory = _factory(
|
||||
tmp_path,
|
||||
{"OLLAMA_API_KEY": "server-key-value"},
|
||||
_FakeKeyStore(key=_FAKE_KEY),
|
||||
)
|
||||
adapter, tier = await factory.resolve(_user(), UserSettings())
|
||||
assert tier == "user"
|
||||
assert adapter.roles().planner == UserSettings().planner_model
|
||||
|
||||
|
||||
async def test_factory_server_tier_when_no_user_key(tmp_path: Path) -> None:
|
||||
factory = _factory(
|
||||
tmp_path,
|
||||
{"OLLAMA_API_KEY": "server-key-value"},
|
||||
_FakeKeyStore(key=None),
|
||||
)
|
||||
_adapter, tier = await factory.resolve(_user(), UserSettings())
|
||||
assert tier == "server"
|
||||
|
||||
|
||||
async def test_factory_keyless_when_neither(tmp_path: Path) -> None:
|
||||
factory = _factory(tmp_path, {}, _FakeKeyStore(key=None))
|
||||
_adapter, tier = await factory.resolve(_user(), UserSettings())
|
||||
assert tier == "keyless"
|
||||
|
||||
|
||||
async def test_factory_keyless_when_store_disabled(tmp_path: Path) -> None:
|
||||
factory = _factory(
|
||||
tmp_path,
|
||||
{"OLLAMA_API_KEY": "server-key-value"},
|
||||
_FakeKeyStore(key=None, enabled=False),
|
||||
)
|
||||
_adapter, tier = await factory.resolve(_user(), UserSettings())
|
||||
assert tier == "server" # chain proceeds to server when key store disabled
|
||||
|
||||
|
||||
async def test_factory_respects_user_settings_models(tmp_path: Path) -> None:
|
||||
factory = _factory(tmp_path, {}, _FakeKeyStore(key=None))
|
||||
settings = UserSettings(planner_model="my-planner", writer_model="my-writer")
|
||||
adapter, tier = await factory.resolve(_user(), settings)
|
||||
assert tier == "keyless"
|
||||
assert adapter.roles().planner == "my-planner"
|
||||
assert adapter.roles().writer == "my-writer"
|
||||
|
||||
|
||||
async def test_factory_never_caches_adapters(tmp_path: Path) -> None:
|
||||
factory = _factory(tmp_path, {}, _FakeKeyStore(key=None))
|
||||
first, _ = await factory.resolve(_user(), UserSettings())
|
||||
second, _ = await factory.resolve(_user(), UserSettings())
|
||||
assert first is not second # one instance per run (P-2 closure)
|
||||
|
||||
|
||||
async def test_factory_user_key_precedence_over_server_secrets(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The resolved user key must be the Authorization the adapter sends
|
||||
— not the server env-file key."""
|
||||
seen_headers: dict[str, str] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_headers.update(dict(request.headers))
|
||||
return httpx.Response(
|
||||
200, json={"choices": [{"message": {"content": '{"ok": true}'}}]}
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs)
|
||||
)
|
||||
|
||||
factory = _factory(
|
||||
tmp_path,
|
||||
{"OLLAMA_API_KEY": "server-key-value"},
|
||||
_FakeKeyStore(key=_FAKE_KEY),
|
||||
)
|
||||
adapter, tier = await factory.resolve(_user(), UserSettings())
|
||||
assert tier == "user"
|
||||
await adapter.chat(
|
||||
[__import__("researchx.adapters.types", fromlist=["ModelMessage"]).ModelMessage(
|
||||
role="user", content="hi"
|
||||
)],
|
||||
model="w",
|
||||
)
|
||||
assert seen_headers["authorization"] == f"Bearer {_FAKE_KEY}"
|
||||
|
||||
|
||||
# ------------------------------------------------- config status rework
|
||||
|
||||
|
||||
async def test_config_status_tier_per_user_no_cross_leak(tmp_path: Path) -> None:
|
||||
"""Config status shows the CURRENT user's tier: a keyed user shows
|
||||
'user', a keyless second user shows 'server' — zero runs needed
|
||||
(P-1 closure), zero cross-user bleed."""
|
||||
app = _app(tmp_path, secrets_values={KEK_NAME: _KEK, "OLLAMA_API_KEY": "server-key-value"})
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_and_login(admin)
|
||||
await admin.put(
|
||||
"/api/config/settings/model-key",
|
||||
json={"key": _FAKE_KEY},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
config = await admin.get("/api/config")
|
||||
assert config.status_code == 200
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "user"
|
||||
assert body["key_store"] == "enabled"
|
||||
assert body["key_status"]["configured"] is True
|
||||
assert body["key_status"]["masked"].endswith(_FAKE_KEY[-4:])
|
||||
assert body["cost_counters"] == {"runs_created": 0, "runs_completed": 0}
|
||||
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
config = await second.get("/api/config")
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "server" # no user key of their own
|
||||
assert body["key_store"] == "enabled" # the STORE is enabled; the user has no key
|
||||
assert body["key_status"]["configured"] is False
|
||||
assert _FAKE_KEY not in config.text
|
||||
assert _FAKE_KEY[-4:] not in config.text
|
||||
# Counters owner-keyed: the second user sees zero, not the admin's runs.
|
||||
assert body["cost_counters"] == {"runs_created": 0, "runs_completed": 0}
|
||||
|
||||
|
||||
async def test_config_status_keyless_when_no_keys(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
config = await client.get("/api/config")
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "keyless"
|
||||
assert body["key_store"] == "disabled"
|
||||
assert body["key_status"]["disabled"] is True
|
||||
assert body["model_adapter"] == "OllamaModelAdapter" # factory-constructed
|
||||
|
||||
|
||||
async def test_config_status_works_with_zero_runs(tmp_path: Path) -> None:
|
||||
"""P-1: correct config status with zero runs in the registry."""
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
response = await client.get("/api/config")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["resolved_tier"] in ("user", "server", "keyless")
|
||||
assert response.json()["cost_counters"]["runs_created"] == 0
|
||||
|
||||
|
||||
async def test_config_status_probe_cached_and_error_safe(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Probe semantics pinned hermetically: a successful probe is
|
||||
cached in-process (second call does not re-probe), and an
|
||||
AdapterError probe yields probe: None (never a 500)."""
|
||||
import researchx.api.app as app_module
|
||||
|
||||
calls: list[int] = []
|
||||
probe_payload = {
|
||||
"endpoint": "http://127.0.0.1:1/v1",
|
||||
"models": ["m"],
|
||||
"context_window": 8192,
|
||||
"embedding_batch": 8,
|
||||
"structured_outputs_supported": True,
|
||||
}
|
||||
from researchx.adapters.probe import CapabilityProbe
|
||||
|
||||
fake_probe = CapabilityProbe(
|
||||
endpoint="http://127.0.0.1:1/v1",
|
||||
models=["m"],
|
||||
context_window=8192,
|
||||
embedding_batch=8,
|
||||
structured_outputs_supported=True,
|
||||
)
|
||||
|
||||
async def _fake_probe(adapter):
|
||||
calls.append(1)
|
||||
return fake_probe
|
||||
|
||||
async def _failing_probe(adapter):
|
||||
from researchx.adapters.types import AdapterError
|
||||
|
||||
raise AdapterError("unreachable")
|
||||
|
||||
monkeypatch.setattr(app_module, "probe_model_adapter", _fake_probe)
|
||||
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
first = await client.get("/api/config")
|
||||
assert first.status_code == 200
|
||||
assert first.json()["probe"]["endpoint"] == probe_payload["endpoint"]
|
||||
assert len(calls) == 1
|
||||
second = await client.get("/api/config")
|
||||
assert len(calls) == 1 # served from the (user, tier, fingerprint) cache
|
||||
assert second.json()["probe"]["endpoint"] == probe_payload["endpoint"]
|
||||
|
||||
# Fresh app: a failing probe yields None, cached — never a 500, and
|
||||
# the route stays healthy on repeat calls.
|
||||
monkeypatch.setattr(app_module, "probe_model_adapter", _failing_probe)
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
first = await client.get("/api/config")
|
||||
assert first.status_code == 200
|
||||
assert first.json()["probe"] is None
|
||||
second = await client.get("/api/config")
|
||||
assert second.status_code == 200
|
||||
assert second.json()["probe"] is None
|
||||
|
||||
|
||||
async def test_config_status_uses_factory_adapter_not_fixed_one(tmp_path: Path) -> None:
|
||||
"""The rework: model_adapter names the FACTORY-constructed adapter
|
||||
(OllamaModelAdapter), not the fixed injected fake."""
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
config = await client.get("/api/config")
|
||||
assert config.json()["model_adapter"] == "OllamaModelAdapter"
|
||||
|
||||
|
||||
async def test_config_status_without_factory_names_fixed_adapter(tmp_path: Path) -> None:
|
||||
"""Back-compat: with no factory wired (pure test harness), the
|
||||
config status still answers with the injected adapter's name."""
|
||||
async with await _client(tmp_path, with_factory=False) as client:
|
||||
await _register_and_login(client)
|
||||
config = await client.get("/api/config")
|
||||
body = config.json()
|
||||
assert body["model_adapter"] == "FakeModelAdapter"
|
||||
assert body["resolved_tier"] == "keyless"
|
||||
|
||||
|
||||
# ----------------------------------------------- create_run tier stamping
|
||||
|
||||
|
||||
async def test_create_run_stamps_credential_tier(tmp_path: Path) -> None:
|
||||
"""create_run resolves the factory and stamps state.credential_tier
|
||||
(REQ-74); the engine contracts are otherwise unchanged."""
|
||||
async with await _client(tmp_path) as client:
|
||||
me = await _register_and_login(client)
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
state_path = tmp_path / "runs" / me["user_id"] / run_id / "state.json"
|
||||
import asyncio
|
||||
|
||||
for _ in range(200):
|
||||
if state_path.exists():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
assert state["credential_tier"] == "keyless"
|
||||
assert state["owner_id"] == me["user_id"]
|
||||
|
||||
|
||||
async def test_create_run_user_tier_uses_user_key(tmp_path: Path) -> None:
|
||||
"""A keyed user's run executes with THEIR key through the factory
|
||||
chain (fake model still serves the run — zero-key CI)."""
|
||||
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
|
||||
me = await _register_and_login(client)
|
||||
await client.put(
|
||||
"/api/config/settings/model-key", json={"key": _FAKE_KEY}, headers=_HEADERS
|
||||
)
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
import asyncio
|
||||
|
||||
state_path = tmp_path / "runs" / me["user_id"] / run_id / "state.json"
|
||||
for _ in range(200):
|
||||
if state_path.exists():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
assert state["credential_tier"] == "user"
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Restart sweep + registry pruning tests (T-1.8, D-115/G-101, P-6)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.api.app import RunRegistry, create_app
|
||||
from researchx.api.sweep import sweep_in_flight_runs
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
from researchx.engine.run_state import RunState
|
||||
from researchx.engine.states import RunStateName, can_transition
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
_OWNER = "abcd2345" # owner slug used for fixture trees
|
||||
|
||||
|
||||
def _state_file(run_dir: Path) -> Path:
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
return run_dir / "state.json"
|
||||
|
||||
|
||||
def _write_state(run_dir: Path, state: RunStateName) -> Path:
|
||||
state_path = _state_file(run_dir)
|
||||
record = RunState(
|
||||
run_id=run_dir.name,
|
||||
question="q",
|
||||
methodology_slug="rapid-answer",
|
||||
state=state,
|
||||
owner_id=_OWNER,
|
||||
)
|
||||
state_path.write_text(record.model_dump_json(), encoding="utf-8")
|
||||
return state_path
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- G-101
|
||||
|
||||
|
||||
def test_planning_to_cancelled_is_now_legal() -> None:
|
||||
"""G-101: the sweep's PLANNING -> CANCELLED rewrite is a legal transition."""
|
||||
assert can_transition(RunStateName.PLANNING, RunStateName.CANCELLED)
|
||||
|
||||
|
||||
def test_sweep_rewrites_planning_fixture_to_cancelled(tmp_path: Path) -> None:
|
||||
base = tmp_path
|
||||
_write_state(base / "runs" / _OWNER / "abcd2345", RunStateName.PLANNING)
|
||||
|
||||
swept = sweep_in_flight_runs(base)
|
||||
assert swept == [(_OWNER, "abcd2345")]
|
||||
|
||||
state = RunState.model_validate_json(
|
||||
(base / "runs" / _OWNER / "abcd2345" / "state.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert state.state == RunStateName.CANCELLED
|
||||
|
||||
|
||||
def test_sweep_rewrites_every_non_terminal_state(tmp_path: Path) -> None:
|
||||
base = tmp_path
|
||||
non_terminal = [
|
||||
RunStateName.PLANNING,
|
||||
RunStateName.AWAITING_APPROVAL,
|
||||
RunStateName.SEARCHING,
|
||||
RunStateName.EXTRACTING,
|
||||
RunStateName.REFLECTING,
|
||||
RunStateName.SYNTHESIZING,
|
||||
RunStateName.VERIFYING,
|
||||
]
|
||||
for state in non_terminal:
|
||||
_write_state(base / "runs" / _OWNER / f"run{state.value[:4].lower()}", state)
|
||||
|
||||
swept = sweep_in_flight_runs(base)
|
||||
assert len(swept) == len(non_terminal)
|
||||
for _, run_id in swept:
|
||||
record = RunState.model_validate_json(
|
||||
(base / "runs" / _OWNER / run_id / "state.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert record.state == RunStateName.CANCELLED
|
||||
|
||||
|
||||
def test_sweep_leaves_terminal_states_untouched(tmp_path: Path) -> None:
|
||||
base = tmp_path
|
||||
_write_state(base / "runs" / _OWNER / "done0001", RunStateName.DONE)
|
||||
_write_state(base / "runs" / _OWNER / "fail0001", RunStateName.FAILED)
|
||||
_write_state(base / "runs" / _OWNER / "canc0001", RunStateName.CANCELLED)
|
||||
|
||||
assert sweep_in_flight_runs(base) == []
|
||||
for run_id in ["done0001", "fail0001", "canc0001"]:
|
||||
record = RunState.model_validate_json(
|
||||
(base / "runs" / _OWNER / run_id / "state.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert record.state in {RunStateName.DONE, RunStateName.FAILED, RunStateName.CANCELLED}
|
||||
|
||||
|
||||
def test_sweep_skips_corrupt_state_and_dotfiles(tmp_path: Path) -> None:
|
||||
base = tmp_path
|
||||
good = _write_state(base / "runs" / _OWNER / "abcd2345", RunStateName.SEARCHING)
|
||||
corrupt = _state_file(base / "runs" / _OWNER / "zzzz9999")
|
||||
corrupt.write_text("{not json", encoding="utf-8")
|
||||
(base / "runs" / _OWNER / ".hidden").mkdir() # dotfile dir skipped
|
||||
(base / "runs" / ".hidden-owner").mkdir() # dotfile owner skipped
|
||||
|
||||
swept = sweep_in_flight_runs(base)
|
||||
assert swept == [(_OWNER, "abcd2345")]
|
||||
assert corrupt.read_text(encoding="utf-8") == "{not json" # untouched
|
||||
del good
|
||||
|
||||
|
||||
def test_sweep_on_empty_base_dir_is_noop(tmp_path: Path) -> None:
|
||||
assert sweep_in_flight_runs(tmp_path) == []
|
||||
assert sweep_in_flight_runs(tmp_path / "missing") == []
|
||||
|
||||
|
||||
def test_create_app_sweeps_on_construction(tmp_path: Path) -> None:
|
||||
"""The PLANNING fixture -> create_app -> state is CANCELLED on disk."""
|
||||
_write_state(tmp_path / "runs" / _OWNER / "abcd2345", RunStateName.PLANNING)
|
||||
|
||||
create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[ChatResult(content=_PLAN, model="fake-planner")]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
|
||||
state = RunState.model_validate_json(
|
||||
(tmp_path / "runs" / _OWNER / "abcd2345" / "state.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert state.state == RunStateName.CANCELLED
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- P-6
|
||||
|
||||
|
||||
async def test_registry_prune_drops_terminal_run_with_zero_subscribers() -> None:
|
||||
registry = RunRegistry()
|
||||
assert registry.prune("abcd2345", "efgh6789") is False # unknown entry: no-op
|
||||
|
||||
# Build a fake terminal entry.
|
||||
from researchx.engine.events import EventBus
|
||||
|
||||
bus = EventBus()
|
||||
|
||||
class _FakeActor:
|
||||
pass
|
||||
|
||||
class _FakeState:
|
||||
state = RunStateName.DONE
|
||||
|
||||
fake_actor = _FakeActor()
|
||||
fake_actor.state = _FakeState() # type: ignore[assignment]
|
||||
|
||||
async def _noop() -> RunState:
|
||||
raise NotImplementedError
|
||||
|
||||
task: object = asyncio.Future() # placeholder, never awaited
|
||||
registry.add("abcd2345", "efgh6789", fake_actor, task, bus) # type: ignore[arg-type]
|
||||
assert registry.has("abcd2345", "efgh6789")
|
||||
|
||||
# A live subscriber blocks pruning.
|
||||
registry.subscribe_events("abcd2345", "efgh6789")
|
||||
assert registry.prune("abcd2345", "efgh6789") is False
|
||||
assert registry.has("abcd2345", "efgh6789")
|
||||
|
||||
# Last subscriber leaves -> terminal run pruned.
|
||||
registry.unsubscribe_events("abcd2345", "efgh6789")
|
||||
assert registry.prune("abcd2345", "efgh6789") is True
|
||||
assert not registry.has("abcd2345", "efgh6789")
|
||||
assert registry.prune("abcd2345", "efgh6789") is False # already gone
|
||||
|
||||
|
||||
def test_registry_refcounts_multiple_subscribers() -> None:
|
||||
registry = RunRegistry()
|
||||
registry.subscribe_events("abcd2345", "efgh6789")
|
||||
registry.subscribe_events("abcd2345", "efgh6789")
|
||||
assert registry.subscriber_count("abcd2345", "efgh6789") == 2
|
||||
registry.unsubscribe_events("abcd2345", "efgh6789")
|
||||
assert registry.subscriber_count("abcd2345", "efgh6789") == 1
|
||||
registry.unsubscribe_events("abcd2345", "efgh6789")
|
||||
assert registry.subscriber_count("abcd2345", "efgh6789") == 0
|
||||
registry.unsubscribe_events("abcd2345", "efgh6789") # underflow-safe
|
||||
assert registry.subscriber_count("abcd2345", "efgh6789") == 0
|
||||
|
||||
|
||||
async def test_events_for_pruned_run_404(tmp_path: Path) -> None:
|
||||
"""Terminal run + all subscribers gone -> registry drops it; the
|
||||
events endpoint 404s afterwards (documented acceptable — the
|
||||
trace persists on disk, D-115/P-6)."""
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
|
||||
)
|
||||
run_id = created.json()["id"]
|
||||
|
||||
for _ in range(200):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "awaiting_approval":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
await client.post(f"/api/runs/{run_id}/approve", headers={CSRF_HEADER: "1"})
|
||||
for _ in range(200):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "done":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# Consume the full SSE replay; on stream end the run prunes.
|
||||
collected = b""
|
||||
async with client.stream("GET", f"/api/runs/{run_id}/events") as response:
|
||||
assert response.status_code == 200
|
||||
async for chunk in response.aiter_bytes():
|
||||
collected += chunk
|
||||
if b"run_done" in collected:
|
||||
break
|
||||
await asyncio.sleep(0.1) # let the finally-block prune
|
||||
|
||||
# The report still exists on disk...
|
||||
report = await client.get(f"/api/runs/{run_id}/report")
|
||||
assert report.status_code in (200, 404) # read-model parity lands in P3
|
||||
state_path = list((tmp_path / "runs").glob(f"*/{run_id}/state.json"))
|
||||
assert state_path, "trace/state persists on disk after prune"
|
||||
|
||||
# ...and a second events request for the pruned run 404s (the
|
||||
# documented post-prune behavior).
|
||||
assert (await client.get(f"/api/runs/{run_id}/events")).status_code == 404
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Store-level tests for the auth package (T-1.1/T-1.2/T-1.3, REQ-69)."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from researchx.auth.sessions import SESSION_TTL_SECONDS, Session, SessionStore
|
||||
from researchx.auth.users import MIN_PASSWORD_LENGTH, UserError, UserStore
|
||||
|
||||
_EMAIL = "owner@example.org"
|
||||
_PASSWORD = "correct-horse-battery"
|
||||
|
||||
|
||||
def _store(tmp_path: Path) -> UserStore:
|
||||
return UserStore(tmp_path / "users.json")
|
||||
|
||||
|
||||
def _sessions(tmp_path: Path) -> SessionStore:
|
||||
return SessionStore(tmp_path / "sessions.json")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ users
|
||||
|
||||
|
||||
async def test_user_store_create_get_list(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
assert store.empty() is True
|
||||
|
||||
user = await store.create_user(
|
||||
email=_EMAIL, password=_PASSWORD, display_name="Owner", role="admin"
|
||||
)
|
||||
assert store.empty() is False
|
||||
assert user.role == "admin"
|
||||
assert user.active is True
|
||||
assert len(user.user_id) == 8
|
||||
assert user.user_id.isascii() and user.user_id.islower()
|
||||
|
||||
fetched = store.get(user.user_id)
|
||||
assert fetched is not None and fetched.email == _EMAIL
|
||||
by_email = store.get_by_email("Owner@Example.ORG")
|
||||
assert by_email is not None and by_email.user_id == user.user_id
|
||||
|
||||
listed = store.list()
|
||||
assert [u.user_id for u in listed] == [user.user_id]
|
||||
assert store.get("zzzz9999") is None
|
||||
|
||||
|
||||
async def test_password_policy_min_12_enforced(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
with pytest.raises(UserError, match="at least 12"):
|
||||
await store.create_user(
|
||||
email=_EMAIL, password="short", display_name="Owner"
|
||||
)
|
||||
assert len("short") < MIN_PASSWORD_LENGTH
|
||||
assert store.empty() is True # nothing inserted
|
||||
|
||||
with pytest.raises(UserError):
|
||||
await store.create_user(
|
||||
email=_EMAIL, password="a" * (MIN_PASSWORD_LENGTH - 1), display_name="Owner"
|
||||
)
|
||||
assert store.empty() is True
|
||||
|
||||
ok = await store.create_user(
|
||||
email=_EMAIL, password="a" * MIN_PASSWORD_LENGTH, display_name="Owner"
|
||||
)
|
||||
assert ok.user_id
|
||||
|
||||
|
||||
async def test_duplicate_email_rejected(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
await store.create_user(email=_EMAIL, password=_PASSWORD, display_name="A")
|
||||
with pytest.raises(UserError, match="already registered"):
|
||||
await store.create_user(
|
||||
email="OWNER@example.org", password=_PASSWORD, display_name="B"
|
||||
)
|
||||
assert len(store.list()) == 1
|
||||
|
||||
|
||||
async def test_argon2_hash_verify_round_trip(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
user = await store.create_user(
|
||||
email=_EMAIL, password=_PASSWORD, display_name="Owner"
|
||||
)
|
||||
assert user.password_hash != _PASSWORD
|
||||
assert user.password_hash.startswith("$argon2")
|
||||
|
||||
assert await store.verify_password(user, _PASSWORD) is True
|
||||
wrong = user.model_copy(update={"password_hash": user.password_hash})
|
||||
assert await store.verify_password(wrong, "wrong-password-entirely") is False
|
||||
|
||||
other = await _store(tmp_path).create_user(
|
||||
email="b@example.org", password="another-password-12", display_name="B"
|
||||
)
|
||||
assert other.password_hash != user.password_hash # unique salts
|
||||
|
||||
|
||||
async def test_user_store_persists_across_reload(tmp_path: Path) -> None:
|
||||
path = tmp_path / "users.json"
|
||||
store = UserStore(path)
|
||||
user = await store.create_user(
|
||||
email=_EMAIL, password=_PASSWORD, display_name="Owner", role="admin"
|
||||
)
|
||||
assert path.exists()
|
||||
|
||||
reloaded = UserStore(path)
|
||||
fetched = reloaded.get(user.user_id)
|
||||
assert fetched is not None
|
||||
assert fetched.email == _EMAIL
|
||||
assert await reloaded.verify_password(fetched, _PASSWORD) # hash survives the round-trip
|
||||
assert reloaded.get_by_email("OWNER@EXAMPLE.ORG") is not None
|
||||
|
||||
|
||||
async def test_user_store_deactivate(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
user = await store.create_user(
|
||||
email=_EMAIL, password=_PASSWORD, display_name="Owner"
|
||||
)
|
||||
updated = store.deactivate(user.user_id)
|
||||
assert updated is not None and updated.active is False
|
||||
assert store.get(user.user_id).active is False
|
||||
assert store.deactivate("zzzz9999") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- sessions
|
||||
|
||||
|
||||
async def test_session_create_get_round_trip(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
user = await store.create_user(email=_EMAIL, password=_PASSWORD, display_name="Owner")
|
||||
sessions = _sessions(tmp_path)
|
||||
|
||||
token = sessions.generate_token()
|
||||
session = sessions.create(token, user.user_id)
|
||||
assert session.token_hash != token # hash-only storage
|
||||
assert len(session.token_hash) == 64 # sha256 hex
|
||||
|
||||
resolved = sessions.get(token)
|
||||
assert resolved is not None
|
||||
assert resolved.user_id == user.user_id
|
||||
assert sessions.get("not-a-real-token") is None
|
||||
|
||||
|
||||
async def test_session_expiry(tmp_path: Path) -> None:
|
||||
sessions = _sessions(tmp_path)
|
||||
token = sessions.generate_token()
|
||||
session = sessions.create(token, "abcd2345")
|
||||
assert session.expires_at > datetime.now(UTC)
|
||||
|
||||
# Expired sessions resolve to None and are evicted.
|
||||
expired = Session(
|
||||
token_hash=session.token_hash,
|
||||
user_id="abcd2345",
|
||||
created_at=datetime.now(UTC) - timedelta(days=15),
|
||||
last_seen=datetime.now(UTC) - timedelta(days=15),
|
||||
expires_at=datetime.now(UTC) - timedelta(seconds=1),
|
||||
)
|
||||
sessions._sessions[session.token_hash] = expired
|
||||
assert sessions.get(token) is None
|
||||
assert session.token_hash not in sessions._sessions
|
||||
|
||||
|
||||
async def test_session_sliding_touch(tmp_path: Path) -> None:
|
||||
sessions = _sessions(tmp_path)
|
||||
token = sessions.generate_token()
|
||||
session = sessions.create(token, "abcd2345")
|
||||
original_expiry = session.expires_at
|
||||
|
||||
old_seen = datetime.now(UTC) - timedelta(days=10)
|
||||
stale = session.model_copy(
|
||||
update={"last_seen": old_seen, "expires_at": old_seen + timedelta(seconds=1)}
|
||||
)
|
||||
sessions._sessions[stale.token_hash] = stale
|
||||
sessions.touch(stale)
|
||||
|
||||
renewed = sessions.get(token)
|
||||
assert renewed is not None
|
||||
assert renewed.expires_at > original_expiry - timedelta(days=10)
|
||||
assert renewed.last_seen > old_seen
|
||||
assert renewed.expires_at - renewed.last_seen == timedelta(seconds=SESSION_TTL_SECONDS)
|
||||
|
||||
|
||||
def test_session_touch_persist_throttle(tmp_path: Path) -> None:
|
||||
sessions = _sessions(tmp_path)
|
||||
token = sessions.generate_token()
|
||||
session = sessions.create(token, "abcd2345")
|
||||
path = sessions._path
|
||||
|
||||
first_write = path.stat().st_mtime_ns
|
||||
sessions.touch(session) # within 60s of the create persist → throttled
|
||||
assert path.stat().st_mtime_ns == first_write
|
||||
|
||||
sessions._last_persist -= 61.0 # simulate the throttle window elapsing
|
||||
session = sessions.get(token)
|
||||
assert session is not None
|
||||
sessions.touch(session)
|
||||
assert path.stat().st_mtime_ns != first_write
|
||||
|
||||
|
||||
async def test_session_revoke_semantics(tmp_path: Path) -> None:
|
||||
sessions = _sessions(tmp_path)
|
||||
token_a = sessions.generate_token()
|
||||
token_b = sessions.generate_token()
|
||||
token_c = sessions.generate_token()
|
||||
sessions.create(token_a, "abcd2345")
|
||||
sessions.create(token_b, "abcd2345")
|
||||
sessions.create(token_c, "efgh6789")
|
||||
|
||||
sessions.revoke(token_a)
|
||||
assert sessions.get(token_a) is None
|
||||
assert sessions.get(token_b) is not None # siblings survive single revoke
|
||||
|
||||
sessions.revoke_all_for("abcd2345")
|
||||
assert sessions.get(token_b) is None
|
||||
assert sessions.get(token_c) is not None # other users untouched
|
||||
|
||||
sessions.revoke("unknown-token") # idempotent
|
||||
sessions.revoke_all_for("zzzz9999")
|
||||
|
||||
|
||||
async def test_session_store_persists_across_reload(tmp_path: Path) -> None:
|
||||
path = tmp_path / "sessions.json"
|
||||
sessions = SessionStore(path)
|
||||
token = sessions.generate_token()
|
||||
created = sessions.create(token, "abcd2345")
|
||||
del created
|
||||
|
||||
reloaded = SessionStore(path)
|
||||
assert reloaded.get(token) is not None
|
||||
reloaded.revoke(token)
|
||||
assert SessionStore(path).get(token) is None # revocation persisted too
|
||||
|
||||
|
||||
# ------------------------------------------------------- corruption posture
|
||||
|
||||
|
||||
def test_corrupt_user_store_fails_closed(tmp_path: Path) -> None:
|
||||
"""A corrupt users.json must NOT silently become an empty store:
|
||||
swallowing it would reopen registration on top of existing
|
||||
accounts and the next persist would overwrite them. The store
|
||||
must refuse to start (file moved aside for operator recovery)."""
|
||||
path = tmp_path / "users.json"
|
||||
path.write_text("{not json", encoding="utf-8")
|
||||
|
||||
with pytest.raises(RuntimeError, match="corrupt user store"):
|
||||
UserStore(path)
|
||||
|
||||
salvage = list(tmp_path.glob("users.json.corrupt.*"))
|
||||
assert salvage, "the corrupt file must be moved aside, not deleted"
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_corrupt_session_store_fails_toward_no_access(tmp_path: Path) -> None:
|
||||
"""A corrupt sessions.json degrades to 'everyone logged out' —
|
||||
the fail-safe direction for sessions (no access, not all access).
|
||||
Startup must still succeed; sessions are re-created on next login."""
|
||||
path = tmp_path / "sessions.json"
|
||||
path.write_text("{not json", encoding="utf-8")
|
||||
|
||||
sessions = SessionStore(path) # must not raise
|
||||
token = sessions.generate_token()
|
||||
sessions.create(token, "abcd2345")
|
||||
assert SessionStore(path).get(token) is not None # recovers on rewrite
|
||||
Generated
+205
-1
@@ -3,7 +3,8 @@ revision = 3
|
||||
requires-python = ">=3.12"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version < '3.15'",
|
||||
"python_full_version == '3.14.*'",
|
||||
"python_full_version < '3.14'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -28,6 +29,61 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi-bindings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi-bindings"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb", size = 25683, upload-time = "2026-08-20T07:33:05.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6", size = 27310, upload-time = "2026-08-20T07:33:06.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990", size = 26771, upload-time = "2026-08-20T07:33:07.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08", size = 27569, upload-time = "2026-08-20T07:33:08.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca", size = 27279, upload-time = "2026-08-20T07:33:09.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1", size = 27774, upload-time = "2026-08-20T07:33:10.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36", size = 26933, upload-time = "2026-08-20T07:33:11.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210", size = 24875, upload-time = "2026-08-20T07:33:12.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4", size = 26655, upload-time = "2026-08-20T07:33:13.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440", size = 25376, upload-time = "2026-08-20T07:33:14.491Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-serialize"
|
||||
version = "0.11.2"
|
||||
@@ -101,6 +157,91 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
@@ -119,6 +260,56 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.115.14"
|
||||
@@ -379,6 +570,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.5"
|
||||
@@ -512,6 +712,8 @@ name = "researchx"
|
||||
version = "0.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
@@ -530,6 +732,8 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "argon2-cffi", specifier = ">=23.1,<26" },
|
||||
{ name = "cryptography", specifier = ">=43" },
|
||||
{ name = "fastapi", specifier = ">=0.115,<0.116" },
|
||||
{ name = "httpx", specifier = ">=0.27,<1" },
|
||||
{ name = "pydantic", specifier = ">=2.8,<3" },
|
||||
|
||||
@@ -14,6 +14,23 @@ FROM node:22-alpine AS build
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Hosted build profile (PLAN.md T-4.3, RESEARCH-v03.md section 10): Next.js
|
||||
# inlines NEXT_PUBLIC_* at BUILD time, so the profile is ARG/ENV here --
|
||||
# never runtime flags. live mode + EMPTY base ("" = same-origin through the
|
||||
# front proxy's /api path route): a non-empty base would break the
|
||||
# same-origin session cookie behind the TLS front.
|
||||
ARG NEXT_PUBLIC_RESEARCHX_API_MODE=live
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_RESEARCHX_API_MODE="${NEXT_PUBLIC_RESEARCHX_API_MODE}" \
|
||||
NEXT_PUBLIC_API_BASE_URL="${NEXT_PUBLIC_API_BASE_URL}"
|
||||
|
||||
# Build assert: the hosted profile is hardwired -- mode=live, empty base --
|
||||
# or the build fails before a wrong image can be produced.
|
||||
RUN [ "$NEXT_PUBLIC_RESEARCHX_API_MODE" = "live" ] \
|
||||
&& [ -z "$NEXT_PUBLIC_API_BASE_URL" ] \
|
||||
|| { echo "hosted build profile violated: mode=$NEXT_PUBLIC_RESEARCHX_API_MODE" >&2; exit 1; }
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
+30
-7
@@ -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
|
||||
|
||||
|
||||
+1019
-1
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,9 @@
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"generate:api": "openapi-typescript ./openapi.json -o src/lib/api/generated/schema.d.ts",
|
||||
"check:api-drift": "openapi-typescript ./openapi.json -o src/lib/api/generated/schema.d.ts --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.5.6",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { AppTopBar } from "@/components/app-top-bar";
|
||||
import { SessionGate } from "@/components/session-gate";
|
||||
|
||||
/**
|
||||
* Root layout (T-5.2, REQ-63) — the persistent app shell.
|
||||
@@ -10,6 +11,10 @@ import { AppTopBar } from "@/components/app-top-bar";
|
||||
* slots) and top bar (⌘K palette + avatar). The shell adds zero
|
||||
* required clicks to the happy path — it is pure wayfinding chrome.
|
||||
* Page content lands in one <main> landmark per route (a11y).
|
||||
*
|
||||
* v0.3 P1 (T-1.10): the SessionGate wraps the shell in live mode —
|
||||
* an unauthenticated visitor is redirected to /login before any
|
||||
* protected content renders. Mock mode passes straight through.
|
||||
*/
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -26,13 +31,15 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="flex min-h-dvh">
|
||||
<AppSidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<AppTopBar />
|
||||
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
|
||||
<SessionGate>
|
||||
<div className="flex min-h-dvh">
|
||||
<AppSidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<AppTopBar />
|
||||
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SessionGate>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LoginView } from "@/components/login-view";
|
||||
|
||||
/**
|
||||
* /login (T-1.10, REQ-69) — the session entry point in live mode.
|
||||
* Live-mode users land here via the app-shell guard when no session
|
||||
* exists; mock mode never routes here (no auth — REQ-31).
|
||||
*/
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign in — ResearchX",
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginView />;
|
||||
}
|
||||
@@ -180,4 +180,19 @@ describe("UserMenu (REQ-66)", () => {
|
||||
expect(await screen.findByText("Researcher")).toBeInTheDocument();
|
||||
expect(screen.getByText("Local workspace — single user")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("logout calls the boundary then routes to /login (T-1.10, REQ-69)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<UserMenu onNavigate={() => {}} onDismiss={onDismiss} />,
|
||||
);
|
||||
const logout = await screen.findByRole("menuitem", { name: /Log out/i });
|
||||
expect(logout).toBeInTheDocument();
|
||||
await user.click(logout);
|
||||
await vi.waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,8 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
type DisplayProfile,
|
||||
type ReportLibrary,
|
||||
type UserProfile,
|
||||
} from "@/lib/api/client";
|
||||
import { CommandPalette, type PaletteEntry } from "@/components/command-palette";
|
||||
import { UserMenu } from "@/components/user-menu";
|
||||
@@ -121,7 +121,7 @@ const AvatarButton = forwardRef<HTMLButtonElement, {
|
||||
menuOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}>(function AvatarButton({ menuOpen, onToggle }, ref) {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [profile, setProfile] = useState<DisplayProfile | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
|
||||
@@ -19,6 +19,7 @@ import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
liveMode,
|
||||
type InboxItem,
|
||||
type ProjectGraph,
|
||||
type ReportConnection,
|
||||
@@ -424,10 +425,19 @@ function ProjectGraphPanel({ graph }: { graph: ProjectGraph }) {
|
||||
/* Task inbox panel (REQ-60) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* G-109 (task-inbox honesty): in LIVE mode the status is DERIVED
|
||||
* server-side (answered-iff the source run reached DONE — D-114), so
|
||||
* the checkbox is display-honest — disabled and annotated, never a
|
||||
* free toggle that silently reverts on the next fetch. Persisted
|
||||
* per-question check state is v0.4+ (REQ-45 backlog). MOCK mode keeps
|
||||
* the free toggle byte-for-byte (the dev fixture owns its state).
|
||||
*/
|
||||
function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (liveMode) return; // display-honest: live status is derived, not local
|
||||
setItems((current) =>
|
||||
current.map((item) =>
|
||||
item.id === id
|
||||
@@ -452,9 +462,16 @@ function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
Open questions worth following up · {openCount} open
|
||||
</p>
|
||||
<p className="mt-1 text-caption text-ink-faint">
|
||||
Toggles are local-only — the inbox persists in v0.2.
|
||||
</p>
|
||||
{liveMode ? (
|
||||
<p className="mt-1 text-caption text-ink-faint" data-testid="inbox-derived-note">
|
||||
A question is answered when its research run completes — status
|
||||
follows the run.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-caption text-ink-faint">
|
||||
Toggles are local-only — the inbox persists in v0.2.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{items.map((item) => (
|
||||
@@ -463,7 +480,15 @@ function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
|
||||
type="checkbox"
|
||||
checked={item.status === "done"}
|
||||
onChange={() => toggle(item.id)}
|
||||
// G-109: derived status in live mode — disabled, never a
|
||||
// silent revert; mock keeps the free toggle.
|
||||
disabled={liveMode}
|
||||
aria-label={item.question}
|
||||
title={
|
||||
liveMode
|
||||
? "Follows its research run — answered when the run completes"
|
||||
: undefined
|
||||
}
|
||||
className="mt-1 h-4 w-4 accent-[var(--color-accent)]"
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Live-mode component tests (T-3.9, G-109; REQ-68, REQ-80).
|
||||
*
|
||||
* The previously-delegated surfaces driving REAL components in live
|
||||
* mode — no boundary mocks, the ApiClient surface only:
|
||||
* - G-109: the task-inbox checkbox is DISPLAY-HONEST in live mode —
|
||||
* derived status (open-iff run not DONE), disabled + annotated;
|
||||
* a click never flips it; the mock keeps the free toggle (pinned
|
||||
* by the unchanged library-view suite).
|
||||
* - Refine honesty: a 503 from the real planner endpoint surfaces
|
||||
* the error state — never a hang, never synthetic questions.
|
||||
*
|
||||
* House pattern (user-menu-session suite): the module's `api` +
|
||||
* `liveMode` exports are swapped per case through module
|
||||
* interception; the components themselves are UNTOUCHED.
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AskView } from "@/components/ask-view";
|
||||
import { LibraryView } from "@/components/library-view";
|
||||
import type {
|
||||
InboxItem,
|
||||
ProjectGraph,
|
||||
ReportConnection,
|
||||
ReportLibrary,
|
||||
} from "@/lib/api/client";
|
||||
|
||||
const pushMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
}));
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
api: {} as Record<string, unknown>,
|
||||
liveMode: false,
|
||||
}));
|
||||
vi.mock("@/lib/api/client", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
Promise<typeof import("@/lib/api/client")>
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
api: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
const handler = state.api[prop as string];
|
||||
return typeof handler === "function"
|
||||
? (handler as (...args: unknown[]) => unknown).bind(state.api)
|
||||
: handler;
|
||||
},
|
||||
},
|
||||
),
|
||||
get liveMode() {
|
||||
return state.liveMode;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Live-mode boundary stub — payloads verbatim from the read-models */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LIVE_LIBRARY: ReportLibrary = {
|
||||
folders: [
|
||||
{
|
||||
id: "deep-report-battery",
|
||||
name: "Deep Report: Battery",
|
||||
description: "Auto-filed from deep report battery research.",
|
||||
},
|
||||
],
|
||||
reports: [
|
||||
{
|
||||
id: "rep-runaaaa1",
|
||||
runId: "runaaaa1",
|
||||
title: "Battery Advances",
|
||||
question: "What about battery research?",
|
||||
methodologySlug: "deep-report",
|
||||
category: "deep-report-battery",
|
||||
generatedAt: "2026-09-20T10:05:00Z",
|
||||
citationCount: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const LIVE_CONNECTIONS: ReportConnection[] = [
|
||||
{
|
||||
fromReportId: "rep-runaaaa1",
|
||||
toReportId: "rep-runbbbb1",
|
||||
kind: "shared-sources",
|
||||
label: "2 shared sources",
|
||||
},
|
||||
];
|
||||
|
||||
const LIVE_GRAPH: ProjectGraph = {
|
||||
nodes: [
|
||||
{
|
||||
id: "runaaaa1",
|
||||
topic: "Battery Advances",
|
||||
linkedSources: ["https://example.org/x"],
|
||||
findings: ["Battery Advances"],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
const LIVE_INBOX: InboxItem[] = [
|
||||
{
|
||||
id: "runaaaa1#0",
|
||||
question: "What is the research question?",
|
||||
sourceRunId: "runaaaa1",
|
||||
status: "open",
|
||||
},
|
||||
{
|
||||
id: "runbbbb1#0",
|
||||
question: "An answered question",
|
||||
sourceRunId: "runbbbb1",
|
||||
status: "done",
|
||||
},
|
||||
];
|
||||
|
||||
/** A live-shaped ApiClient surface for the library panels. */
|
||||
function liveLibraryApi(): Record<string, unknown> {
|
||||
return {
|
||||
getReportLibrary: vi.fn().mockResolvedValue(LIVE_LIBRARY),
|
||||
getReportConnections: vi.fn().mockResolvedValue(LIVE_CONNECTIONS),
|
||||
getProjectGraph: vi.fn().mockResolvedValue(LIVE_GRAPH),
|
||||
getTaskInbox: vi.fn().mockResolvedValue(LIVE_INBOX),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* G-109 — the task-inbox checkbox is display-honest in live mode */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("LibraryView task inbox — live-mode honesty (G-109)", () => {
|
||||
it("renders every live surface: folders, connections, graph, tasks", async () => {
|
||||
state.api = liveLibraryApi();
|
||||
state.liveMode = true;
|
||||
render(<LibraryView />);
|
||||
|
||||
// Library feeds from the LIVE read-model payload (T-3.9).
|
||||
const card = await screen.findByRole("link", { name: /Battery Advances/i });
|
||||
expect(card).toHaveAttribute("href", "/run/runaaaa1/report");
|
||||
expect(screen.getByRole("button", { name: /Deep Report: Battery/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 citation/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Shared sources — 2 shared sources/i),
|
||||
).toBeInTheDocument();
|
||||
// The graph panel renders the run as a node (topic = report title).
|
||||
const graph = await screen.findByRole("region", { name: "Project graph" });
|
||||
expect(graph.textContent).toContain("Battery Advances");
|
||||
});
|
||||
|
||||
it("live checkboxes are DISABLED and the derived-status note shows", async () => {
|
||||
state.api = liveLibraryApi();
|
||||
state.liveMode = true;
|
||||
render(<LibraryView />);
|
||||
|
||||
await screen.findByRole("region", { name: "Task inbox" });
|
||||
const open = await screen.findByRole("checkbox", {
|
||||
name: "What is the research question?",
|
||||
});
|
||||
const done = await screen.findByRole("checkbox", {
|
||||
name: "An answered question",
|
||||
});
|
||||
// Derived status rendered — open stays open, done stays done.
|
||||
expect(open).not.toBeChecked();
|
||||
expect(done).toBeChecked();
|
||||
// G-109: display-honest — both disabled.
|
||||
expect(open).toBeDisabled();
|
||||
expect(done).toBeDisabled();
|
||||
// Annotated, never silent.
|
||||
expect(
|
||||
screen.getByTestId("inbox-derived-note"),
|
||||
).toHaveTextContent(/answered when its research run completes/i);
|
||||
// The open count reflects the server-derived statuses.
|
||||
expect(
|
||||
screen.getByText(/open questions worth following up · 1 open/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a click on a live checkbox never flips the derived status", async () => {
|
||||
state.api = liveLibraryApi();
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<LibraryView />);
|
||||
|
||||
const open = await screen.findByRole("checkbox", {
|
||||
name: "What is the research question?",
|
||||
});
|
||||
await user.click(open);
|
||||
// Still unchecked — no silent revert because nothing ever changed.
|
||||
expect(open).not.toBeChecked();
|
||||
expect(
|
||||
screen.getByText(/open questions worth following up · 1 open/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("mock mode keeps the free toggle byte-for-byte (G-109 dev fixture)", async () => {
|
||||
state.api = liveLibraryApi();
|
||||
state.liveMode = false;
|
||||
const user = userEvent.setup();
|
||||
render(<LibraryView />);
|
||||
|
||||
const open = await screen.findByRole("checkbox", {
|
||||
name: "What is the research question?",
|
||||
});
|
||||
expect(open).toBeEnabled();
|
||||
expect(
|
||||
screen.queryByTestId("inbox-derived-note"),
|
||||
).toBeNull();
|
||||
await user.click(open);
|
||||
expect(open).toBeChecked();
|
||||
expect(
|
||||
screen.getByText(/open questions worth following up · 0 open/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Refine — the real exchange with an honest 503 (T-3.6, REQ-80) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("RefineDrawer — live-mode 503 honesty", () => {
|
||||
it("surfaces the honest unavailable state — never a hang, never synthetic", async () => {
|
||||
state.api = {
|
||||
getSuggestedQueries: vi.fn().mockResolvedValue([]),
|
||||
getRefinementQuestions: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("live: refine is unavailable — 503")),
|
||||
composeRefinedQuestion: vi.fn(),
|
||||
};
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<AskView />);
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText(/what do you want to know\?/i),
|
||||
"What are the latest advances in solid-state batteries",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Refine question/i }));
|
||||
expect(
|
||||
await screen.findByRole("region", { name: /Refine your question/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// The honest error state — the drawer never invents questions.
|
||||
expect(
|
||||
await screen.findByRole("alert"),
|
||||
).toHaveTextContent(/Refine is unavailable right now/i);
|
||||
expect(
|
||||
screen.queryByLabelText(/matters most to you/i),
|
||||
).toBeNull();
|
||||
|
||||
// Keep original closes the drawer with the draft untouched.
|
||||
await user.click(screen.getByRole("button", { name: "Keep original" }));
|
||||
expect(
|
||||
screen.queryByRole("region", { name: /Refine your question/i }),
|
||||
).toBeNull();
|
||||
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
|
||||
"What are the latest advances in solid-state batteries",
|
||||
);
|
||||
});
|
||||
|
||||
it("drives the REAL live exchange: model questions → model composition", async () => {
|
||||
state.api = {
|
||||
getSuggestedQueries: vi.fn().mockResolvedValue([]),
|
||||
getRefinementQuestions: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "q-1",
|
||||
prompt: "Which application domain matters most?",
|
||||
placeholder: "one short answer",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
id: "q-2",
|
||||
prompt: "What decision will this research inform?",
|
||||
placeholder: "one short answer",
|
||||
optional: true,
|
||||
},
|
||||
]),
|
||||
composeRefinedQuestion: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
question: "Solid-state batteries — automotive applications",
|
||||
context: "Feeds a supplier choice.",
|
||||
}),
|
||||
};
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<AskView />);
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText(/what do you want to know\?/i),
|
||||
"What are the latest advances in solid-state batteries",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Refine question/i }));
|
||||
|
||||
const q1 = await screen.findByLabelText(/Which application domain matters most\?/i);
|
||||
await user.type(q1, "automotive applications");
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
|
||||
const q2 = await screen.findByLabelText(/What decision will this research inform\?/i);
|
||||
await user.type(q2, "choosing a supplier");
|
||||
await user.click(screen.getByRole("button", { name: "Compose question" }));
|
||||
|
||||
// The model-composed refinement arrives and fills the form.
|
||||
expect(
|
||||
await screen.findByText("Solid-state batteries — automotive applications"),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Use this" }));
|
||||
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
|
||||
"Solid-state batteries — automotive applications",
|
||||
);
|
||||
expect(screen.getByLabelText(/anything else it should know\?/i)).toHaveValue(
|
||||
"Feeds a supplier choice.",
|
||||
);
|
||||
});
|
||||
|
||||
it("live suggestions render with source attribution and never auto-start", async () => {
|
||||
state.api = {
|
||||
getSuggestedQueries: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "sug-runcccc1#0",
|
||||
question: "What is the research question?",
|
||||
sourceRunId: "runcccc1",
|
||||
sourceReportTitle: "Deep Report — an in-flight run",
|
||||
},
|
||||
]),
|
||||
getRefinementQuestions: vi.fn(),
|
||||
composeRefinedQuestion: vi.fn(),
|
||||
};
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<AskView />);
|
||||
|
||||
const card = await screen.findByRole("button", {
|
||||
name: /What is the research question\?/i,
|
||||
});
|
||||
expect(
|
||||
screen.getByText(/From your research — Deep Report — an in-flight run/i),
|
||||
).toBeInTheDocument();
|
||||
await user.click(card);
|
||||
// Click-fills the box — never auto-starts.
|
||||
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
|
||||
"What is the research question?",
|
||||
);
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a fresh user sees the honest empty suggestions state in live mode", async () => {
|
||||
state.api = {
|
||||
getSuggestedQueries: vi.fn().mockResolvedValue([]),
|
||||
getRefinementQuestions: vi.fn(),
|
||||
composeRefinedQuestion: vi.fn(),
|
||||
};
|
||||
state.liveMode = true;
|
||||
render(<AskView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/No open questions yet — they collect here as you run research/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* LoginView tests (T-1.10, REQ-69).
|
||||
*
|
||||
* - Renders the email + password form with a disabled submit until both
|
||||
* fields are filled
|
||||
* - Submit routes through the boundary: success → /ask; failure → the
|
||||
* uniform "invalid email or password" error state (401, no
|
||||
* user enumeration)
|
||||
*
|
||||
* The house pattern mocks NO boundary — instead this suite injects a
|
||||
* boundary-shaped stub through module interception because login is
|
||||
* the one call whose FAILURE path must be driven deterministically
|
||||
* (the mock binding never fails a login). The stub speaks only the
|
||||
* ApiClient surface.
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { LoginView } from "@/components/login-view";
|
||||
import { AuthError, type ApiClient } from "@/lib/api/client";
|
||||
|
||||
const pushMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
usePathname: () => "/login",
|
||||
}));
|
||||
|
||||
/** A boundary-shaped stub driving success/failure of login (REQ-69). */
|
||||
const loginMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api/client", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
Promise<typeof import("@/lib/api/client")>
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...(actual.api as unknown as ApiClient),
|
||||
login: (...args: [string, string]) => loginMock(...args),
|
||||
} as unknown as ApiClient,
|
||||
};
|
||||
});
|
||||
|
||||
const SESSION_USER = {
|
||||
user_id: "abcd2345",
|
||||
email: "owner@example.org",
|
||||
display_name: "Owner",
|
||||
role: "admin",
|
||||
active: true,
|
||||
created_at: "2026-09-29T12:00:00Z",
|
||||
};
|
||||
|
||||
describe("LoginView (REQ-69)", () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear();
|
||||
loginMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders email + password fields and a disabled submit until both are set", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginView />);
|
||||
|
||||
const email = screen.getByLabelText("Email");
|
||||
const password = screen.getByLabelText("Password");
|
||||
const submit = screen.getByRole("button", { name: "Sign in" });
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await user.type(email, "owner@example.org");
|
||||
expect(submit).toBeDisabled(); // password still empty
|
||||
await user.type(password, "not-a-real-secret");
|
||||
expect(submit).toBeEnabled();
|
||||
});
|
||||
|
||||
it("submits through the boundary and redirects to /ask on success", async () => {
|
||||
loginMock.mockResolvedValueOnce(SESSION_USER);
|
||||
const user = userEvent.setup();
|
||||
render(<LoginView />);
|
||||
|
||||
await user.type(screen.getByLabelText("Email"), "owner@example.org");
|
||||
await user.type(
|
||||
screen.getByLabelText("Password"),
|
||||
"correct-horse-battery",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
expect(loginMock).toHaveBeenCalledWith(
|
||||
"owner@example.org",
|
||||
"correct-horse-battery",
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/ask");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the uniform 'invalid email or password' error on a rejected login", async () => {
|
||||
// The backend answers every failure mode uniformly (REQ-69).
|
||||
loginMock.mockRejectedValueOnce(new AuthError("invalid email or password", 401));
|
||||
const user = userEvent.setup();
|
||||
render(<LoginView />);
|
||||
|
||||
await user.type(screen.getByLabelText("Email"), "owner@example.org");
|
||||
await user.type(screen.getByLabelText("Password"), "wrong-password");
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"invalid email or password",
|
||||
);
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
// The submit re-enables for a retry.
|
||||
expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("surfaces an honest unavailable state on a non-auth failure", async () => {
|
||||
loginMock.mockRejectedValueOnce(new Error("network down"));
|
||||
const user = userEvent.setup();
|
||||
render(<LoginView />);
|
||||
|
||||
await user.type(screen.getByLabelText("Email"), "owner@example.org");
|
||||
await user.type(screen.getByLabelText("Password"), "whatever");
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"sign-in unavailable",
|
||||
);
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LoginView (T-1.10, REQ-69) — the /login screen client.
|
||||
*
|
||||
* Email + password against POST /api/auth/login through the ApiClient
|
||||
* boundary. On success the app lands on /ask (the front door); on 401
|
||||
* the error is the backend's uniform "invalid email or password" — no
|
||||
* user enumeration, client-side included. A first-run hint points at
|
||||
* the backend's register-once rule (the first user is admin).
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, AuthError } from "@/lib/api/client";
|
||||
|
||||
export function LoginView() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSubmit = email.trim() !== "" && password !== "" && !submitting;
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
api
|
||||
.login(email.trim(), password)
|
||||
.then(() => {
|
||||
router.push("/ask");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Uniform 401 copy — the backend never says WHICH half failed.
|
||||
if (err instanceof AuthError) {
|
||||
setError("invalid email or password");
|
||||
} else {
|
||||
setError("sign-in unavailable — try again");
|
||||
}
|
||||
setSubmitting(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col px-6 py-section">
|
||||
<header className="mb-block">
|
||||
<h1 className="text-display font-semibold text-ink">Sign in.</h1>
|
||||
<p className="measure-reading mt-4 text-lead text-ink-soft">
|
||||
ResearchX runs under your account — your research library,
|
||||
reports, and settings are yours alone.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<label htmlFor="email" className="block text-body font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="you@example.org"
|
||||
className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
|
||||
/>
|
||||
|
||||
<label htmlFor="password" className="mt-6 block text-body font-medium">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••••••"
|
||||
className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
|
||||
/>
|
||||
|
||||
{error !== null && (
|
||||
<p role="alert" className="mt-4 text-small text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="mt-8 rounded-pill bg-accent px-7 py-3 text-body font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{submitting ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-block text-caption text-ink-faint">
|
||||
First run? The first account created becomes the administrator.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Reading Room live parity + honest exports (T-3.10, REQ-82, REQ-59,
|
||||
* REQ-34 — P-12 closure pins).
|
||||
*
|
||||
* The REAL ReadingRoom component rendering a REAL live payload —
|
||||
* the enriched GET /api/runs/{id}/report envelope (report.json +
|
||||
* evidence.json + markdown + ledger) stubbed at fetch level:
|
||||
* - structured sections render with [^pN] markers (zero
|
||||
* report-body.ts changes — C3-10);
|
||||
* - hover/focus on a marker reveals the EXACT stored quoted passage
|
||||
* (parity with the mock Reading Room, REQ-82);
|
||||
* - the ledger panel lists real entries with real quotes;
|
||||
* - exports carry REAL data: markdown from the served markdown,
|
||||
* JSON with real claims_supported/health/tier/quotes — the
|
||||
* fabricated claims_supported:1 and empty quotes are DEAD, and a
|
||||
* test pins their absence (P-12).
|
||||
*
|
||||
* House pattern (user-menu-session suite): the module's `api` export
|
||||
* is swapped per case through a hoisted module interception backed by
|
||||
* a GeneratedApiClient over a stubbed fetch — the components
|
||||
* themselves are UNTOUCHED, and every call still speaks only the
|
||||
* ApiClient surface.
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ReadingRoom } from "@/components/reading-room";
|
||||
import { GeneratedApiClient } from "@/lib/api/live-client";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
api: {} as Record<string, unknown>,
|
||||
}));
|
||||
vi.mock("@/lib/api/client", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
Promise<typeof import("@/lib/api/client")>
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
api: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
const handler = state.api[prop as string];
|
||||
return typeof handler === "function"
|
||||
? (handler as (...args: unknown[]) => unknown).bind(state.api)
|
||||
: handler;
|
||||
},
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Fixture live payload — the DONE-run envelope, verbatim shapes */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const RUN_ID = "runaaaa1";
|
||||
|
||||
const FIXTURE_ENVELOPE = {
|
||||
run_id: RUN_ID,
|
||||
markdown:
|
||||
"# Battery Advances\n\nThe verified core finding [p-1]. A second corroborated observation [p-2].\n",
|
||||
report: {
|
||||
v: 1,
|
||||
run_id: RUN_ID,
|
||||
owner_id: "abcd2345",
|
||||
title: "Battery Advances",
|
||||
question: "What about battery research?",
|
||||
methodology_slug: "deep-report",
|
||||
generated_at: "2026-09-20T10:05:00Z",
|
||||
sections: [
|
||||
{
|
||||
heading: "Research Memo",
|
||||
body: "The verified core finding [^p1]. A second corroborated observation [^p2].",
|
||||
},
|
||||
],
|
||||
citations: [
|
||||
{
|
||||
passage_id: "p1",
|
||||
url: "https://example.org/x",
|
||||
title: "Verification passes",
|
||||
quote: "The verified core finding.",
|
||||
health: "live",
|
||||
authority_tier: "official",
|
||||
},
|
||||
{
|
||||
passage_id: "p2",
|
||||
url: "https://example.com/y",
|
||||
title: "Quote anchoring and auditability",
|
||||
quote: "Anchoring makes auditability real.",
|
||||
health: "stale",
|
||||
authority_tier: "general",
|
||||
},
|
||||
],
|
||||
},
|
||||
evidence: {
|
||||
v: 1,
|
||||
run_id: RUN_ID,
|
||||
passages: [
|
||||
{
|
||||
passage_id: "p1",
|
||||
url: "https://example.org/x",
|
||||
title: "Verification passes",
|
||||
quote: "The verified core finding.",
|
||||
text: "The verified core finding in full, stored at DONE.",
|
||||
},
|
||||
{
|
||||
passage_id: "p2",
|
||||
url: "https://example.com/y",
|
||||
title: "Quote anchoring and auditability",
|
||||
quote: "Anchoring makes auditability real.",
|
||||
text: "Anchoring is the mechanism that makes auditability real.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const FIXTURE_LEDGER = {
|
||||
run_id: RUN_ID,
|
||||
generated_at: "2026-09-20T10:05:00Z",
|
||||
entries: [
|
||||
{
|
||||
url: "https://example.org/x",
|
||||
visited_at: "2026-09-20T10:04:00Z",
|
||||
passages: ["p-1"],
|
||||
claims_supported: 2,
|
||||
health: "live",
|
||||
authority_tier: "official",
|
||||
},
|
||||
{
|
||||
url: "https://example.com/y",
|
||||
visited_at: "2026-09-20T10:04:30Z",
|
||||
passages: ["p-2"],
|
||||
claims_supported: 1,
|
||||
health: "stale",
|
||||
authority_tier: "general",
|
||||
},
|
||||
],
|
||||
checklist: {
|
||||
run_id: RUN_ID,
|
||||
citations_checked: 2,
|
||||
live: 1,
|
||||
stale: 1,
|
||||
dead: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/** A fetch stub serving the DONE-run report envelope + ledger. */
|
||||
function liveStackFetch(): typeof globalThis.fetch {
|
||||
const routes: Record<string, unknown> = {
|
||||
[`http://127.0.0.1:8000/api/runs/${RUN_ID}/report`]: FIXTURE_ENVELOPE,
|
||||
[`http://127.0.0.1:8000/api/runs/${RUN_ID}/ledger`]: FIXTURE_LEDGER,
|
||||
};
|
||||
return (async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
for (const [prefix, body] of Object.entries(routes)) {
|
||||
if (url.startsWith(prefix)) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ detail: "not found" }), { status: 404 });
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
/** The live client over the stubbed stack — the boundary per case. */
|
||||
function liveApi(): Record<string, unknown> {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
return client as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.api = liveApi();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component parity — the REAL ReadingRoom over the live payload */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("ReadingRoom live parity (T-3.10, REQ-82)", () => {
|
||||
it("renders structured sections + [^pN] markers + the real title", async () => {
|
||||
render(<ReadingRoom id={RUN_ID} />);
|
||||
|
||||
// The REAL report.json title — not a placeholder.
|
||||
expect(await screen.findByText("Battery Advances")).toBeInTheDocument();
|
||||
// Structured section heading from report.json.
|
||||
expect(screen.getByText("Research Memo")).toBeInTheDocument();
|
||||
// Section body text renders around the parsed [^pN] markers —
|
||||
// both text segments of the memo section are present.
|
||||
const memo = screen.getByText("Research Memo").closest("section");
|
||||
expect(memo?.textContent).toContain("The verified core finding ");
|
||||
expect(memo?.textContent).toContain("A second corroborated observation");
|
||||
// Markers render as citation buttons (marker index from pN).
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: /Citation 1:/i }).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: /Citation 2:/i }).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hover/focus reveals the EXACT stored quoted passage (REQ-82 parity)", async () => {
|
||||
render(<ReadingRoom id={RUN_ID} />);
|
||||
|
||||
const markers = await screen.findAllByRole("button", { name: /Citation 1:/i });
|
||||
markers[0]?.focus();
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
// The stored passage quote — exactly what the engine anchored.
|
||||
expect(tooltip.textContent).toContain("The verified core finding.");
|
||||
expect(tooltip.textContent).toContain("Verification passes");
|
||||
expect(tooltip.textContent).toContain("example.org");
|
||||
// Tier map: official → 1.
|
||||
expect(tooltip.textContent).toContain("tier 1");
|
||||
});
|
||||
|
||||
it("the ledger panel lists the REAL entries with quotes + health", async () => {
|
||||
render(<ReadingRoom id={RUN_ID} />);
|
||||
|
||||
expect(await screen.findByText("Citation ledger")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("2 sources · every claim traceable to a visited passage"),
|
||||
).toBeInTheDocument();
|
||||
// Real entry titles + real quotes.
|
||||
expect(screen.getByText("Verification passes")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Quote anchoring and auditability"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/“The verified core finding\.”/)).toBeInTheDocument();
|
||||
// Health badges from the real ledger health values.
|
||||
expect(screen.getAllByText(/live/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/stale/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("markdown-only fallback renders honestly for a pre-artifact DONE run", async () => {
|
||||
// Swap in a pre-artifact envelope: markdown only, null artifacts —
|
||||
// with a ledger still served (the run reached DONE and the ledger
|
||||
// predates report.json; the route never 500s — D-106).
|
||||
const preArtifact = new GeneratedApiClient(
|
||||
(async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.includes("/report")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
run_id: "runbbbb1",
|
||||
markdown: "# Old Report\n\nRaw [p-1] markers only.",
|
||||
report: null,
|
||||
evidence: null,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url.includes("/ledger")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
run_id: "runbbbb1",
|
||||
generated_at: "2026-09-19T10:05:00Z",
|
||||
entries: [],
|
||||
checklist: { run_id: "runbbbb1", citations_checked: 0 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ detail: "not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}) as typeof globalThis.fetch,
|
||||
);
|
||||
state.api = preArtifact as unknown as Record<string, unknown>;
|
||||
|
||||
render(<ReadingRoom id="runbbbb1" />);
|
||||
|
||||
// The served markdown, verbatim — never a 500, never fabricated
|
||||
// sections or citations.
|
||||
expect(await screen.findByText("Report")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Raw \[p-1\] markers only\./i)).toBeInTheDocument();
|
||||
// No citation markers as buttons — there are no citations.
|
||||
expect(screen.queryByRole("button", { name: /Citation/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Honest exports — P-12 closure pins (boundary level) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("exportReport — real data, P-12 fabrication dead (T-3.10, REQ-34)", () => {
|
||||
it("markdown export carries the SERVED markdown + a real-quote citation block", async () => {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
const markdown = await client.exportReport(RUN_ID, "markdown");
|
||||
expect(markdown.filename).toBe(`researchx-${RUN_ID}.md`);
|
||||
// The served report.md verbatim — raw [p-N] markers intact (C3-10).
|
||||
expect(markdown.content).toContain("# Battery Advances");
|
||||
expect(markdown.content).toContain("The verified core finding [p-1].");
|
||||
// Citations block: real URLs as links + real health/tier.
|
||||
expect(markdown.content).toContain("](https://example.org/x)");
|
||||
expect(markdown.content).toContain("✓ live");
|
||||
expect(markdown.content).toContain("△ stale");
|
||||
// The REAL stored quote appears in the citation block.
|
||||
expect(markdown.content).toContain("The verified core finding.");
|
||||
});
|
||||
|
||||
it("JSON export carries REAL claims_supported/health/tier/quotes per entry", async () => {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
const json = await client.exportReport(RUN_ID, "json");
|
||||
expect(json.filename).toBe(`researchx-${RUN_ID}.json`);
|
||||
const parsed = JSON.parse(json.content) as {
|
||||
run_id: string;
|
||||
generated_at: string;
|
||||
entries: Array<{
|
||||
url: string;
|
||||
visited_at: string;
|
||||
passages: string[];
|
||||
claims_supported: number;
|
||||
health: string;
|
||||
authority_tier: string;
|
||||
quote: string;
|
||||
}>;
|
||||
checklist: Record<string, unknown>;
|
||||
};
|
||||
expect(parsed.run_id).toBe(RUN_ID);
|
||||
expect(parsed.entries).toHaveLength(2);
|
||||
// Entry 0: the REAL values from ledger.json — claims_supported 2
|
||||
// (NOT the fabricated 1), live health, official tier.
|
||||
expect(parsed.entries[0]?.url).toBe("https://example.org/x");
|
||||
expect(parsed.entries[0]?.claims_supported).toBe(2);
|
||||
expect(parsed.entries[0]?.health).toBe("live");
|
||||
expect(parsed.entries[0]?.authority_tier).toBe("official");
|
||||
// Entry 1 keeps its own real values — the entries are not clones.
|
||||
expect(parsed.entries[1]?.claims_supported).toBe(1);
|
||||
expect(parsed.entries[1]?.health).toBe("stale");
|
||||
// The REAL stored quotes — never empty strings.
|
||||
expect(parsed.entries[0]?.quote).toBe("The verified core finding.");
|
||||
expect(parsed.entries[1]?.quote).toBe("Anchoring makes auditability real.");
|
||||
// The real checklist from the ledger artifact.
|
||||
expect(parsed.checklist).toEqual(FIXTURE_LEDGER.checklist);
|
||||
});
|
||||
|
||||
it("P-12 pin: the fabricated claims_supported:1 and empty quotes are ABSENT", async () => {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
const json = await client.exportReport(RUN_ID, "json");
|
||||
// Every entry carries its OWN claims count — an all-1s dump would
|
||||
// mean the fabrication is back (entry 0's real count is 2).
|
||||
const claims = JSON.parse(json.content) as {
|
||||
entries: Array<{ claims_supported: number; quote: string }>;
|
||||
};
|
||||
expect(claims.entries.map((e) => e.claims_supported)).toEqual([2, 1]);
|
||||
// No empty quotes anywhere.
|
||||
for (const entry of claims.entries) {
|
||||
expect(entry.quote.length).toBeGreaterThan(0);
|
||||
}
|
||||
// And the export never emits the mock-era placeholder title either.
|
||||
expect(json.content).not.toContain('"Research Report"');
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* getReport/getLedger mappings — the boundary contract */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("getReport/getLedger live mappings (T-3.10)", () => {
|
||||
it("getReport maps report.json → Report with real quotes + the tier map", async () => {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
const report = await client.getReport(RUN_ID);
|
||||
expect(report.runId).toBe(RUN_ID);
|
||||
expect(report.title).toBe("Battery Advances");
|
||||
expect(report.sections).toEqual([
|
||||
{
|
||||
heading: "Research Memo",
|
||||
body: "The verified core finding [^p1]. A second corroborated observation [^p2].",
|
||||
},
|
||||
]);
|
||||
// [^pN] markers survive byte-for-byte — zero report-body.ts changes.
|
||||
expect(report.sections[0]?.body).toMatch(/\[\^p1\]/);
|
||||
expect(report.citations).toHaveLength(2);
|
||||
// Tier map: official→1, general→3.
|
||||
expect(report.citations[0]).toEqual({
|
||||
id: "c-1",
|
||||
url: "https://example.org/x",
|
||||
title: "Verification passes",
|
||||
site: "example.org",
|
||||
passageId: "p1",
|
||||
quote: "The verified core finding.",
|
||||
verification: "live",
|
||||
authorityTier: 1,
|
||||
});
|
||||
expect(report.citations[1]?.authorityTier).toBe(3);
|
||||
expect(report.citations[1]?.site).toBe("example.com");
|
||||
expect(report.citations[1]?.verification).toBe("stale");
|
||||
});
|
||||
|
||||
it("getLedger maps real entries with pN-normalized ids + stored quotes", async () => {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
const ledger = await client.getLedger(RUN_ID);
|
||||
expect(ledger.runId).toBe(RUN_ID);
|
||||
expect(ledger.generatedAt).toBe("2026-09-20T10:05:00Z");
|
||||
expect(ledger.citations).toHaveLength(2);
|
||||
// ledger.json ids are p-N; the Citation passageId is the pN form
|
||||
// the [^pN] markers parse to.
|
||||
expect(ledger.citations[0]?.passageId).toBe("p1");
|
||||
expect(ledger.citations[0]?.quote).toBe("The verified core finding.");
|
||||
expect(ledger.citations[0]?.authorityTier).toBe(1);
|
||||
expect(ledger.citations[1]?.passageId).toBe("p2");
|
||||
expect(ledger.citations[1]?.verification).toBe("stale");
|
||||
});
|
||||
|
||||
it("the report + ledger share one network read per DONE run (memoized envelope)", async () => {
|
||||
let reportCalls = 0;
|
||||
const counting = new GeneratedApiClient(
|
||||
(async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.includes("/report")) {
|
||||
reportCalls += 1;
|
||||
return new Response(JSON.stringify(FIXTURE_ENVELOPE), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/ledger")) {
|
||||
return new Response(JSON.stringify(FIXTURE_LEDGER), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ detail: "not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}) as typeof globalThis.fetch,
|
||||
);
|
||||
await counting.getReport(RUN_ID);
|
||||
await counting.getLedger(RUN_ID);
|
||||
await counting.exportReport(RUN_ID, "markdown");
|
||||
// report route hit exactly ONCE: the envelope is memoized per run
|
||||
// once report.json exists (a terminal run's artifacts are final).
|
||||
expect(reportCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* SessionGate tests (T-1.10, REQ-69) — the live-mode route guard.
|
||||
*
|
||||
* The gate consults the boundary's getSession() on mount:
|
||||
* - live mode + no session → redirect to /login
|
||||
* - live mode + session → content renders
|
||||
* - live mode + /login route → never gated (the login screen itself)
|
||||
* - mock mode → no getSession call at all, content
|
||||
* renders immediately (REQ-31/REQ-54)
|
||||
*
|
||||
* Mode is a module constant resolved from env at import time, so this
|
||||
* suite exercises the gate's decision logic with the boundary stubbed
|
||||
* per case; the mock-mode fast path is pinned against the REAL
|
||||
* binding (the default mock singleton).
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionGate } from "@/components/session-gate";
|
||||
import type { ApiClient } from "@/lib/api/client";
|
||||
|
||||
const replaceMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: replaceMock }),
|
||||
usePathname: () => "/ask",
|
||||
}));
|
||||
|
||||
const getSessionMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api/client", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
Promise<typeof import("@/lib/api/client")>
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
liveMode: true, // the gate only acts in live mode
|
||||
api: {
|
||||
...(actual.api as unknown as ApiClient),
|
||||
getSession: () => getSessionMock(),
|
||||
} as unknown as ApiClient,
|
||||
};
|
||||
});
|
||||
|
||||
const SESSION_USER = {
|
||||
user_id: "abcd2345",
|
||||
email: "owner@example.org",
|
||||
display_name: "Owner",
|
||||
role: "admin",
|
||||
active: true,
|
||||
created_at: "2026-09-29T12:00:00Z",
|
||||
};
|
||||
|
||||
describe("SessionGate (T-1.10, REQ-69)", () => {
|
||||
beforeEach(() => {
|
||||
replaceMock.mockClear();
|
||||
getSessionMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("redirects an unauthenticated visitor to /login before content renders", async () => {
|
||||
// Persistent mock: React StrictMode double-invokes effects in tests,
|
||||
// so the boundary stub must answer every call the same way.
|
||||
getSessionMock.mockResolvedValue(null);
|
||||
render(
|
||||
<SessionGate>
|
||||
<p>Protected screen</p>
|
||||
</SessionGate>,
|
||||
);
|
||||
// Guard content never flashes for an unauthenticated visitor.
|
||||
expect(screen.queryByText("Protected screen")).toBeNull();
|
||||
await vi.waitFor(() => {
|
||||
expect(replaceMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
expect(getSessionMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the shell once a session resolves", async () => {
|
||||
getSessionMock.mockResolvedValue(SESSION_USER);
|
||||
render(
|
||||
<SessionGate>
|
||||
<p>Protected screen</p>
|
||||
</SessionGate>,
|
||||
);
|
||||
expect(await screen.findByText("Protected screen")).toBeInTheDocument();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when the session probe errors", async () => {
|
||||
getSessionMock.mockRejectedValue(new Error("backend unreachable"));
|
||||
render(
|
||||
<SessionGate>
|
||||
<p>Protected screen</p>
|
||||
</SessionGate>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(replaceMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
expect(screen.queryByText("Protected screen")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -1,26 +1,32 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SettingsView (T-5.4, REQ-65) — the /settings screen.
|
||||
* SettingsView (T-5.4, REQ-65; P2 T-2.9, REQ-72/73/74) — /settings.
|
||||
*
|
||||
* Five sections, all through the ApiClient boundary:
|
||||
* - API keys: masked values (raw keys never reach the UI), endpoint
|
||||
* labels, configured state
|
||||
* - Storage: local directory reports are written to
|
||||
* - Methodology defaults: default methodology + resolved 12-dial
|
||||
* display (the REQ-04 reproducibility vocabulary)
|
||||
* - Appearance: theme slot — preview-marked (paper palette is
|
||||
* light-only today)
|
||||
* - About & version: prototype notice + version
|
||||
*
|
||||
* Save persists through api.saveSettings; the mock round-trips it.
|
||||
* Mock mode keeps the v0.1 five sections byte-for-byte (component
|
||||
* tests pin them). Live/hosted mode adds the per-user surfaces on
|
||||
* top, all through the ApiClient boundary:
|
||||
* - Model credentials (REQ-73/74): resolved credential tier badge
|
||||
* (user | server | keyless), masked model-key entry (password
|
||||
* input → setModelKey; masked display "…1234"; the raw key is
|
||||
* never echoed), honest disabled state when the key store is off
|
||||
* (409 KeyStoreDisabledError → the reason is shown)
|
||||
* - Model names (REQ-72): planner/writer inputs bound to
|
||||
* getUserSettings/saveUserSettings
|
||||
* - Usage (P-13): per-user cost counters (runs created/completed)
|
||||
* - Storage: display-only in live mode (D-111) — the server manages
|
||||
* the directory; no path picker
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
liveMode,
|
||||
type ConfigStatus,
|
||||
type Methodology,
|
||||
type ModelKeyStatus,
|
||||
type Settings,
|
||||
type UserSettings,
|
||||
} from "@/lib/api/client";
|
||||
import { formatDialValue } from "@/lib/dials";
|
||||
|
||||
@@ -29,6 +35,19 @@ type SettingsState =
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; settings: Settings; methodologies: Methodology[] };
|
||||
|
||||
/** Live-mode per-user panel state (T-2.9) — keyed to the boundary. */
|
||||
type UserSettingsState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; settings: UserSettings };
|
||||
|
||||
/** Resolved credential tier (REQ-74) — badge copy per tier. */
|
||||
const TIER_COPY: Record<ConfigStatus["resolved_tier"], string> = {
|
||||
user: "Your key",
|
||||
server: "Server key",
|
||||
keyless: "Keyless (localhost)",
|
||||
};
|
||||
|
||||
export function SettingsView() {
|
||||
const [state, setState] = useState<SettingsState>({ kind: "loading" });
|
||||
const [storage, setStorage] = useState("");
|
||||
@@ -37,6 +56,19 @@ export function SettingsView() {
|
||||
"idle",
|
||||
);
|
||||
|
||||
// ---- live-only per-user surface (T-2.9) --------------------------
|
||||
const [userSettings, setUserSettings] = useState<UserSettingsState>({
|
||||
kind: "loading",
|
||||
});
|
||||
const [configStatus, setConfigStatus] = useState<ConfigStatus | null>(null);
|
||||
const [keyDraft, setKeyDraft] = useState("");
|
||||
const [keyState, setKeyState] = useState<
|
||||
{ kind: "idle" } | { kind: "saving" } | { kind: "saved" } | { kind: "error"; message: string }
|
||||
>({ kind: "idle" });
|
||||
const [modelsSaveState, setModelsSaveState] = useState<"idle" | "saving" | "saved" | "error">(
|
||||
"idle",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
@@ -64,6 +96,34 @@ export function SettingsView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Live mode only: per-user settings + credential/usage status.
|
||||
useEffect(() => {
|
||||
if (!liveMode) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [user, status] = await Promise.all([
|
||||
api.getUserSettings(),
|
||||
api.getConfigStatus(),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setUserSettings({ kind: "ready", settings: user });
|
||||
setConfigStatus(status);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!cancelled) {
|
||||
setUserSettings({
|
||||
kind: "error",
|
||||
message: cause instanceof Error ? cause.message : "Account settings unavailable",
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (state.kind === "loading") {
|
||||
return <p className="mt-8 text-small text-ink-faint">Loading settings…</p>;
|
||||
}
|
||||
@@ -94,6 +154,49 @@ export function SettingsView() {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- live per-user handlers ---------------------------------------
|
||||
|
||||
const userReady = userSettings.kind === "ready" ? userSettings.settings : null;
|
||||
|
||||
const saveModels = async (planner: string, writer: string) => {
|
||||
setModelsSaveState("saving");
|
||||
try {
|
||||
const saved = await api.saveUserSettings({
|
||||
planner_model: planner,
|
||||
writer_model: writer,
|
||||
});
|
||||
if (userSettings.kind === "ready") {
|
||||
setUserSettings({ kind: "ready", settings: saved });
|
||||
}
|
||||
setModelsSaveState("saved");
|
||||
} catch {
|
||||
setModelsSaveState("error");
|
||||
}
|
||||
};
|
||||
|
||||
const submitModelKey = async () => {
|
||||
const raw = keyDraft.trim();
|
||||
if (!raw) return;
|
||||
setKeyState({ kind: "saving" });
|
||||
try {
|
||||
const status = await api.setModelKey(raw);
|
||||
// The raw key NEVER survives this handler — clear the field and
|
||||
// keep only the masked status the boundary returned.
|
||||
setKeyDraft("");
|
||||
setKeyState({ kind: "saved" });
|
||||
if (configStatus) {
|
||||
setConfigStatus({ ...configStatus, key_status: status, resolved_tier: "user" });
|
||||
}
|
||||
} catch (cause) {
|
||||
setKeyDraft("");
|
||||
setKeyState({
|
||||
kind: "error",
|
||||
message:
|
||||
cause instanceof Error ? cause.message : "Could not store the key",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-display font-semibold text-ink">Settings</h1>
|
||||
@@ -103,6 +206,48 @@ export function SettingsView() {
|
||||
</p>
|
||||
|
||||
<div className="mt-block space-y-8">
|
||||
{/* ---- Model credentials (live only, T-2.9) ----------------- */}
|
||||
{liveMode && (
|
||||
<ModelCredentialsSection
|
||||
configStatus={configStatus}
|
||||
keyDraft={keyDraft}
|
||||
onKeyDraft={setKeyDraft}
|
||||
keyState={keyState}
|
||||
onSubmit={submitModelKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ---- Model names (live only, T-2.9) ------------------------ */}
|
||||
{liveMode && (
|
||||
<section
|
||||
aria-label="Model names"
|
||||
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
|
||||
>
|
||||
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Model names
|
||||
</h2>
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
The models your runs use — saved to your account and applied
|
||||
to every new run.
|
||||
</p>
|
||||
{userSettings.kind === "loading" && (
|
||||
<p className="mt-4 text-caption text-ink-faint">Loading…</p>
|
||||
)}
|
||||
{userSettings.kind === "error" && (
|
||||
<p role="alert" className="mt-4 text-caption text-danger">
|
||||
{userSettings.message}
|
||||
</p>
|
||||
)}
|
||||
{userReady && (
|
||||
<ModelNamesForm
|
||||
initial={userReady}
|
||||
saveState={modelsSaveState}
|
||||
onSave={saveModels}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ---- API keys --------------------------------------------- */}
|
||||
<section
|
||||
aria-label="API keys"
|
||||
@@ -154,20 +299,62 @@ export function SettingsView() {
|
||||
Local directory finished reports are written to — no upload
|
||||
quotas, plain Markdown and JSON on disk.
|
||||
</p>
|
||||
<label className="mt-4 block">
|
||||
<span className="text-small font-medium text-ink">Reports directory</span>
|
||||
<input
|
||||
type="text"
|
||||
value={storage}
|
||||
onChange={(event) => {
|
||||
setStorage(event.target.value);
|
||||
setSaveState("idle");
|
||||
}}
|
||||
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
|
||||
/>
|
||||
</label>
|
||||
{liveMode && configStatus ? (
|
||||
// D-111: server-managed, display-only in live mode — no picker.
|
||||
<p
|
||||
data-testid="storage-display"
|
||||
className="mt-4 rounded-card border border-hairline bg-paper-sunken px-3 py-2 font-mono text-small text-ink"
|
||||
>
|
||||
{configStatus.storage_dir}
|
||||
<span className="ml-2 text-caption text-ink-faint">
|
||||
(server-managed — read only)
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<label className="mt-4 block">
|
||||
<span className="text-small font-medium text-ink">Reports directory</span>
|
||||
<input
|
||||
type="text"
|
||||
value={storage}
|
||||
onChange={(event) => {
|
||||
setStorage(event.target.value);
|
||||
setSaveState("idle");
|
||||
}}
|
||||
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ---- Usage (live only, P-13) -------------------------------- */}
|
||||
{liveMode && configStatus && (
|
||||
<section
|
||||
aria-label="Usage"
|
||||
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
|
||||
>
|
||||
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Usage
|
||||
</h2>
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
Your research activity on this server.
|
||||
</p>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-4 rounded-card border border-hairline bg-paper px-4 py-4">
|
||||
<div>
|
||||
<dt className="text-caption text-ink-faint">Runs created</dt>
|
||||
<dd className="text-body font-medium text-ink">
|
||||
{configStatus.cost_counters.runs_created}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-caption text-ink-faint">Runs completed</dt>
|
||||
<dd className="text-body font-medium text-ink">
|
||||
{configStatus.cost_counters.runs_completed}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ---- Methodology defaults ---------------------------------- */}
|
||||
<section
|
||||
aria-label="Methodology defaults"
|
||||
@@ -276,6 +463,220 @@ export function SettingsView() {
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
/* Live-mode per-user subcomponents (T-2.9) */
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Model credentials (REQ-73/74): the resolved credential tier badge,
|
||||
* the masked key status, and the password-shaped key entry. The raw
|
||||
* key lives in the input only until submit; the response is masked
|
||||
* ("…1234"). A disabled key store (409) shows the honest reason.
|
||||
*/
|
||||
function ModelCredentialsSection({
|
||||
configStatus,
|
||||
keyDraft,
|
||||
onKeyDraft,
|
||||
keyState,
|
||||
onSubmit,
|
||||
}: {
|
||||
configStatus: ConfigStatus | null;
|
||||
keyDraft: string;
|
||||
onKeyDraft: (next: string) => void;
|
||||
keyState:
|
||||
| { kind: "idle" }
|
||||
| { kind: "saving" }
|
||||
| { kind: "saved" }
|
||||
| { kind: "error"; message: string };
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const tier = configStatus?.resolved_tier ?? "keyless";
|
||||
const keyStatus: ModelKeyStatus | undefined = configStatus?.key_status;
|
||||
const keyStoreEnabled = configStatus?.key_store === "enabled";
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Model credentials"
|
||||
className="rounded-card border border-hairline bg-paper-raised px-5 py-5"
|
||||
>
|
||||
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Model credentials
|
||||
</h2>
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
Your key is encrypted at rest and shown masked — never displayed raw.
|
||||
</p>
|
||||
|
||||
{/* Resolved credential tier (REQ-74) — badge + honest copy. */}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span className="text-small font-medium text-ink">Credential tier</span>
|
||||
<span
|
||||
data-testid="credential-tier"
|
||||
className={`rounded-pill px-2.5 py-0.5 text-caption ${
|
||||
tier === "user"
|
||||
? "bg-success-wash text-success"
|
||||
: tier === "server"
|
||||
? "bg-accent-wash text-accent-ink"
|
||||
: "bg-warn-wash text-warn"
|
||||
}`}
|
||||
>
|
||||
{TIER_COPY[tier]}
|
||||
</span>
|
||||
<span className="text-caption text-ink-faint">
|
||||
{tier === "user"
|
||||
? "Your runs use your stored key."
|
||||
: tier === "server"
|
||||
? "Your runs use the server's key."
|
||||
: "No key stored — runs fall back to keyless localhost."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Masked key status (REQ-73). */}
|
||||
{keyStatus && (
|
||||
<p
|
||||
data-testid="key-status"
|
||||
className="mt-3 rounded-card border border-hairline bg-paper px-4 py-3 text-caption text-ink-soft"
|
||||
>
|
||||
{keyStatus.configured ? (
|
||||
<>
|
||||
Model key stored —{" "}
|
||||
<span data-testid="key-masked" className="font-medium text-ink">
|
||||
{keyStatus.masked}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"No model key stored."
|
||||
)}
|
||||
{keyStatus.reason && (
|
||||
<span data-testid="key-reason" className="mt-1 block text-warn">
|
||||
{keyStatus.reason}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Key entry — disabled honestly when the store is off (409 class). */}
|
||||
<label className="mt-4 block">
|
||||
<span className="text-small font-medium text-ink">
|
||||
{keyStatus?.configured ? "Replace model key" : "Add model key"}
|
||||
</span>
|
||||
<div className="mt-1 flex max-w-md gap-2">
|
||||
<input
|
||||
type="password"
|
||||
// A disabled key store refuses with 409 — the input reflects
|
||||
// the honest server state instead of pretending to accept.
|
||||
disabled={!keyStoreEnabled}
|
||||
autoComplete="off"
|
||||
value={keyDraft}
|
||||
onChange={(event) => onKeyDraft(event.target.value)}
|
||||
placeholder="sk-…"
|
||||
aria-label="Model key"
|
||||
className="w-full rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!keyStoreEnabled || keyDraft.trim() === "" || keyState.kind === "saving"}
|
||||
className="shrink-0 rounded-pill bg-accent px-4 py-2 text-small font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{keyState.kind === "saving" ? "Storing…" : "Store key"}
|
||||
</button>
|
||||
</div>
|
||||
{!keyStoreEnabled && (
|
||||
<p data-testid="key-store-disabled" className="mt-2 text-caption text-warn">
|
||||
Key storage is disabled on this server —{" "}
|
||||
{keyStatus?.reason ?? "the key store is not configured."}{" "}
|
||||
Runs use the server tier or keyless localhost.
|
||||
</p>
|
||||
)}
|
||||
{keyState.kind === "saved" && (
|
||||
<p role="status" data-testid="key-saved" className="mt-2 text-caption text-success">
|
||||
Key stored — shown masked above.
|
||||
</p>
|
||||
)}
|
||||
{keyState.kind === "error" && (
|
||||
<p role="alert" data-testid="key-error" className="mt-2 text-caption text-danger">
|
||||
{keyState.message}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Planner/writer model-name form (REQ-72) — bound to getUserSettings /
|
||||
* saveUserSettings. Editing either field enables Save; a save
|
||||
* round-trips and re-renders from the returned payload.
|
||||
*/
|
||||
function ModelNamesForm({
|
||||
initial,
|
||||
saveState,
|
||||
onSave,
|
||||
}: {
|
||||
initial: UserSettings;
|
||||
saveState: "idle" | "saving" | "saved" | "error";
|
||||
onSave: (planner: string, writer: string) => void;
|
||||
}) {
|
||||
const [planner, setPlanner] = useState(initial.planner_model);
|
||||
const [writer, setWriter] = useState(initial.writer_model);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const dirty = touched && (planner !== initial.planner_model || writer !== initial.writer_model);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-small font-medium text-ink">Planner model</span>
|
||||
<input
|
||||
type="text"
|
||||
value={planner}
|
||||
onChange={(event) => {
|
||||
setPlanner(event.target.value);
|
||||
setTouched(true);
|
||||
}}
|
||||
placeholder="gpt-oss:20b"
|
||||
aria-label="Planner model"
|
||||
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-small font-medium text-ink">Writer model</span>
|
||||
<input
|
||||
type="text"
|
||||
value={writer}
|
||||
onChange={(event) => {
|
||||
setWriter(event.target.value);
|
||||
setTouched(true);
|
||||
}}
|
||||
placeholder="gpt-oss:20b"
|
||||
aria-label="Writer model"
|
||||
className="mt-1 w-full max-w-md rounded-card border border-hairline bg-paper-sunken px-3 py-2 text-body text-ink"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saveState === "saving"}
|
||||
onClick={() => onSave(planner, writer)}
|
||||
className="rounded-pill bg-accent px-5 py-2 text-small font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{saveState === "saving" ? "Saving…" : "Save models"}
|
||||
</button>
|
||||
{saveState === "saved" && (
|
||||
<p role="status" className="text-caption text-success">
|
||||
Saved — applied to your next runs.
|
||||
</p>
|
||||
)}
|
||||
{saveState === "error" && (
|
||||
<p role="alert" className="text-caption text-danger">
|
||||
Save failed — try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved default-dial display — labels from the DIALS registry.
|
||||
* Previews the currently selected methodology (live), falling back to
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* UserMenu session display tests (T-1.10, REQ-69) — the avatar menu
|
||||
* shows the REAL profile in live mode (display name + email from
|
||||
* getSession) and keeps the fixture in mock mode, plus Logout wiring.
|
||||
*
|
||||
* The menu consults the boundary twice: getUserProfile() for the
|
||||
* fixture display and getSession() for the live account. This suite
|
||||
* stubs both through the ApiClient surface, driving live mode with a
|
||||
* resolved session and mock mode with the real mock binding.
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserMenu } from "@/components/user-menu";
|
||||
import type { ApiClient, UserProfile } from "@/lib/api/client";
|
||||
|
||||
const pushMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
usePathname: () => "/ask",
|
||||
}));
|
||||
|
||||
// Boundary + mode injection: the suite swaps the module's `api` and
|
||||
// `liveMode` exports per case (the menu reads both). The mock's `api`
|
||||
// delegates to state.api at CALL time so re-assignment per test works.
|
||||
const state = vi.hoisted(() => ({
|
||||
api: {} as Record<string, unknown>,
|
||||
liveMode: false,
|
||||
}));
|
||||
vi.mock("@/lib/api/client", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
Promise<typeof import("@/lib/api/client")>
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
api: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
const handler = state.api[prop as string];
|
||||
return typeof handler === "function"
|
||||
? (handler as (...args: unknown[]) => unknown).bind(state.api)
|
||||
: handler;
|
||||
},
|
||||
},
|
||||
),
|
||||
// Getter export: the menu reads liveMode at render time, so the
|
||||
// per-test assignment below must be visible.
|
||||
get liveMode() {
|
||||
return state.liveMode;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SESSION_USER: UserProfile = {
|
||||
user_id: "abcd2345",
|
||||
email: "owner@example.org",
|
||||
display_name: "Ada Owner",
|
||||
role: "admin",
|
||||
active: true,
|
||||
created_at: "2026-09-29T12:00:00Z",
|
||||
};
|
||||
|
||||
/** Boundary-shaped stub with an injectable live-mode flag + session. */
|
||||
function makeApi(overrides: {
|
||||
session: UserProfile | null;
|
||||
logout: () => Promise<void>;
|
||||
}): ApiClient {
|
||||
return {
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(overrides.logout),
|
||||
getSession: vi.fn().mockResolvedValue(overrides.session),
|
||||
listUsers: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
deactivateUser: vi.fn(),
|
||||
getUserProfile: vi.fn().mockResolvedValue({
|
||||
name: "Researcher",
|
||||
initials: "RS",
|
||||
tagline: "Local workspace — single user",
|
||||
}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe("UserMenu session display (T-1.10, REQ-69)", () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("live mode shows the real session profile — display name + email", async () => {
|
||||
state.api = makeApi({ session: SESSION_USER, logout: vi.fn() }) as unknown as Record<string, unknown>;
|
||||
state.liveMode = true;
|
||||
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
|
||||
expect(await screen.findByText("Ada Owner")).toBeInTheDocument();
|
||||
expect(screen.getByText("owner@example.org")).toBeInTheDocument();
|
||||
// The avatar disc carries the real initials.
|
||||
expect(screen.getByText("AO")).toBeInTheDocument();
|
||||
// Never the fixture name in live mode with a resolved session.
|
||||
expect(screen.queryByText("Researcher")).toBeNull();
|
||||
});
|
||||
|
||||
it("mock mode keeps the fixture profile unchanged", async () => {
|
||||
state.api = makeApi({ session: SESSION_USER, logout: vi.fn() }) as unknown as Record<string, unknown>;
|
||||
state.liveMode = false;
|
||||
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
|
||||
expect(await screen.findByText("Researcher")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Local workspace — single user"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("logout calls the boundary then routes to /login", async () => {
|
||||
const logout = vi.fn().mockResolvedValue(undefined);
|
||||
const onDismiss = vi.fn();
|
||||
state.api = makeApi({ session: SESSION_USER, logout }) as unknown as Record<string, unknown>;
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<UserMenu onNavigate={() => {}} onDismiss={onDismiss} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", { name: /Log out/i }),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(logout).toHaveBeenCalledTimes(1);
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("a failed logout still routes away (the cookie dies server-side)", async () => {
|
||||
const logout = vi.fn().mockRejectedValue(new Error("network down"));
|
||||
state.api = makeApi({ session: SESSION_USER, logout }) as unknown as Record<string, unknown>;
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", { name: /Log out/i }),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* UserMenu (T-5.2, REQ-66) — the avatar button's dropdown.
|
||||
* UserMenu (T-5.2, REQ-66; T-1.10, REQ-69) — the avatar button's dropdown.
|
||||
*
|
||||
* Local single-user profile (no auth — single-user constraint), links to
|
||||
* Settings and About (the settings page's about section). Escape and
|
||||
* outside clicks dismiss. Rendered as a menu for assistive tech.
|
||||
* Shows the profile: the real session user (display name + email via
|
||||
* getSession) in live mode, the local fixture in mock mode — and a
|
||||
* Logout action (revokes the session through the boundary, routes to
|
||||
* /login). Escape and outside clicks dismiss. Rendered as a menu for
|
||||
* assistive tech.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { api, type UserProfile } from "@/lib/api/client";
|
||||
import { api, liveMode, type DisplayProfile, type UserProfile } from "@/lib/api/client";
|
||||
|
||||
export function UserMenu({
|
||||
onNavigate,
|
||||
@@ -22,7 +25,10 @@ export function UserMenu({
|
||||
/** The avatar button — clicks on it toggle, not dismiss-then-reopen. */
|
||||
toggleRef?: RefObject<HTMLButtonElement | null>;
|
||||
}) {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const router = useRouter();
|
||||
const [profile, setProfile] = useState<DisplayProfile | null>(null);
|
||||
const [session, setSession] = useState<UserProfile | null>(null);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,11 +41,52 @@ export function UserMenu({
|
||||
.catch(() => {
|
||||
/* fallback initials render below */
|
||||
});
|
||||
// The real session user drives the live-mode display (T-1.10);
|
||||
// mock mode ignores it and keeps the fixture.
|
||||
api
|
||||
.getSession()
|
||||
.then((user) => {
|
||||
if (!cancelled) setSession(user);
|
||||
})
|
||||
.catch(() => {
|
||||
/* menu falls back to the fixture display */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Live mode with a resolved session shows the real account; mock
|
||||
// mode (and any unresolved session) shows the fixture unchanged.
|
||||
const showSession = liveMode && session !== null;
|
||||
const name = showSession
|
||||
? (session as UserProfile).display_name
|
||||
: (profile?.name ?? "Researcher");
|
||||
const detail = showSession
|
||||
? (session as UserProfile).email
|
||||
: (profile?.tagline ?? "Local workspace");
|
||||
const initials = showSession
|
||||
? initialsOf((session as UserProfile).display_name)
|
||||
: (profile?.initials ?? "··");
|
||||
|
||||
// Logout: revoke the session through the boundary, then land on
|
||||
// /login (live mode). Mock mode keeps the identical control — its
|
||||
// logout is a local no-op that still routes, so the surface behaves
|
||||
// the same in both bindings.
|
||||
const handleLogout = () => {
|
||||
if (loggingOut) return;
|
||||
setLoggingOut(true);
|
||||
api
|
||||
.logout()
|
||||
.catch(() => {
|
||||
/* a failed revoke still leaves the app — the cookie dies server-side soonest */
|
||||
})
|
||||
.finally(() => {
|
||||
onDismiss();
|
||||
router.push("/login");
|
||||
});
|
||||
};
|
||||
|
||||
// Escape closes; outside clicks close. Focus stays in the header.
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
@@ -72,15 +119,11 @@ export function UserMenu({
|
||||
aria-hidden="true"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-pill bg-accent text-small font-semibold text-paper-raised"
|
||||
>
|
||||
{profile?.initials ?? "··"}
|
||||
{initials}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-small font-medium text-ink">
|
||||
{profile?.name ?? "Researcher"}
|
||||
</p>
|
||||
<p className="text-caption text-ink-faint">
|
||||
{profile?.tagline ?? "Local workspace"}
|
||||
</p>
|
||||
<p className="text-small font-medium text-ink">{name}</p>
|
||||
<p className="text-caption text-ink-faint">{detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,11 +148,33 @@ export function UserMenu({
|
||||
About & version
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={handleLogout}
|
||||
disabled={loggingOut}
|
||||
className="mt-1 w-full rounded-card px-2 py-2 text-left text-small text-ink transition-colors hover:bg-paper-sunken disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{loggingOut ? "Signing out…" : "Log out"}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="mt-3 border-t border-hairline pt-3 text-caption text-ink-faint">
|
||||
Local profile — accounts and sync land post-MVP.
|
||||
{showSession
|
||||
? `Signed in as ${session?.role} — sessions slide for 14 days.`
|
||||
: "Local profile — accounts and sync land post-MVP."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Two-letter initials from a display name ("Dev User" → "DU"). */
|
||||
function initialsOf(displayName: string): string {
|
||||
const words = displayName.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return "··";
|
||||
const first = words[0]?.[0] ?? "";
|
||||
const second = words[1]?.[0] ?? words[0]?.[1] ?? "";
|
||||
return `${first}${second}`.toUpperCase();
|
||||
}
|
||||
@@ -4,15 +4,21 @@
|
||||
* Components consume ONLY this interface via the `api` singleton. In
|
||||
* v0.1 the singleton is bound to MockApiClient; in v0.2 phase 3 the
|
||||
* binding swaps to the OpenAPI-generated client with no component
|
||||
* rewrites. This is the ONLY module allowed to import MockApiClient.
|
||||
* rewrites. Since v0.3 P3 (D-112) the live client is standalone —
|
||||
* this boundary module selects the binding, and it is the ONLY module
|
||||
* allowed to import MockApiClient.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CitationLedger,
|
||||
ConfigStatus,
|
||||
ConnectionSlot,
|
||||
CreateUserInput,
|
||||
DialValues,
|
||||
DisplayProfile,
|
||||
InboxItem,
|
||||
Methodology,
|
||||
ModelKeyStatus,
|
||||
ProjectGraph,
|
||||
RefinementQuestion,
|
||||
RefinedQuestion,
|
||||
@@ -23,6 +29,7 @@ import type {
|
||||
Settings,
|
||||
SuggestedQuery,
|
||||
TraceEvent,
|
||||
UserSettings,
|
||||
UserProfile,
|
||||
} from "@/lib/types";
|
||||
import { MockApiClient } from "@/lib/api/mock/mock-client";
|
||||
@@ -32,12 +39,16 @@ export type { Report, ReportSection } from "@/lib/types";
|
||||
export type {
|
||||
Citation,
|
||||
CitationLedger,
|
||||
ConfigStatus,
|
||||
ConnectionSlot,
|
||||
CreateUserInput,
|
||||
DialValues,
|
||||
DisplayProfile,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
InboxItem,
|
||||
Methodology,
|
||||
ModelKeyStatus,
|
||||
ProjectGraph,
|
||||
RefinementQuestion,
|
||||
RefinedQuestion,
|
||||
@@ -54,8 +65,16 @@ export type {
|
||||
SuggestedQuery,
|
||||
Task,
|
||||
TraceEvent,
|
||||
UserSettings,
|
||||
UserProfile,
|
||||
} from "@/lib/types";
|
||||
/**
|
||||
* Typed key-store refusal (P2, REQ-73) — thrown by setModelKey when
|
||||
* the backend answers 409 (key store disabled / no KEK). Exported as
|
||||
* a VALUE through the boundary so consumers catch it the same way
|
||||
* they catch AuthError.
|
||||
*/
|
||||
export { KeyStoreDisabledError } from "@/lib/types";
|
||||
|
||||
/** Input to createRun — the Ask + Choose screens' combined payload. */
|
||||
export interface CreateRunInput {
|
||||
@@ -153,8 +172,11 @@ export interface ApiClient {
|
||||
*/
|
||||
getReportConnections(): Promise<ReportConnection[]>;
|
||||
|
||||
/** The local single-user profile behind the avatar (REQ-66). */
|
||||
getUserProfile(): Promise<UserProfile>;
|
||||
/**
|
||||
* The menu/display profile behind the avatar (REQ-66). In live mode
|
||||
* this derives from the session user; in mock mode it is the fixture.
|
||||
*/
|
||||
getUserProfile(): Promise<DisplayProfile>;
|
||||
|
||||
/** Adapter-slot connection status for the shell + settings (REQ-65). */
|
||||
getConnectionStatus(): Promise<ConnectionSlot[]>;
|
||||
@@ -169,6 +191,44 @@ export interface ApiClient {
|
||||
*/
|
||||
saveSettings(update: Partial<Settings>): Promise<Settings>;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* v0.3 P2 per-user settings + model keys (T-2.9 — REQ-72/73/74). */
|
||||
/* C3-18-adjacent extension: the exhaustive P2 additions. The */
|
||||
/* v0.1 Settings surface stays untouched (mock-mode screens keep */
|
||||
/* their behavior byte-for-byte); the per-user surface binds to */
|
||||
/* GET/PUT /api/config/settings and the model-key routes. */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The current user's per-user settings (REQ-72 — GET
|
||||
* /api/config/settings). Fresh users get the server defaults
|
||||
* (planner/writer "gpt-oss:20b", appearance "system").
|
||||
*/
|
||||
getUserSettings(): Promise<UserSettings>;
|
||||
|
||||
/**
|
||||
* Persist a partial per-user settings update (REQ-72 — PUT
|
||||
* /api/config/settings, CSRF header in live mode); returns the
|
||||
* merged UserSettings.
|
||||
*/
|
||||
saveUserSettings(update: Partial<UserSettings>): Promise<UserSettings>;
|
||||
|
||||
/**
|
||||
* Store the user's raw model key (REQ-73 — PUT
|
||||
* /api/config/settings/model-key). The raw key is sent once and
|
||||
* NEVER echoed: the resolved value is masked-only. A disabled
|
||||
* server-side key store rejects with a typed KeyStoreDisabledError
|
||||
* carrying the honest reason (409) — never a silent no-op.
|
||||
*/
|
||||
setModelKey(key: string): Promise<ModelKeyStatus>;
|
||||
|
||||
/**
|
||||
* The current user's credential/usage status (REQ-74 — GET
|
||||
* /api/config): resolved tier, key-store state, masked key status,
|
||||
* adapters, storage dir (display-only), probe, cost counters.
|
||||
*/
|
||||
getConfigStatus(): Promise<ConfigStatus>;
|
||||
|
||||
/**
|
||||
* Follow-up query suggestions for the New Research view —
|
||||
* derived from unanswered open questions of past runs
|
||||
@@ -191,14 +251,75 @@ export interface ApiClient {
|
||||
draft: string,
|
||||
answers: Record<string, string>,
|
||||
): Promise<RefinedQuestion>;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* v0.3 P1 session surface (C3-18 exhaustive list — REQ-69, REQ-70). */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Authenticate with email + password (POST /api/auth/login — sets
|
||||
* the httpOnly session cookie server-side). Rejects with a typed
|
||||
* AuthError on 401 (uniform "invalid email or password").
|
||||
*/
|
||||
login(email: string, password: string): Promise<UserProfile>;
|
||||
|
||||
/** Revoke the current session (POST /api/auth/logout). */
|
||||
logout(): Promise<void>;
|
||||
|
||||
/**
|
||||
* The current session's user, or null when unauthenticated
|
||||
* (GET /api/auth/me — 401 maps to null, never a throw).
|
||||
*/
|
||||
getSession(): Promise<UserProfile | null>;
|
||||
|
||||
/** Admin hook: list all users (GET /api/admin/users — REQ-70). */
|
||||
listUsers(): Promise<UserProfile[]>;
|
||||
|
||||
/** Admin hook: create a user with an admin-set password (POST /api/admin/users). */
|
||||
createUser(input: CreateUserInput): Promise<UserProfile>;
|
||||
|
||||
/**
|
||||
* Admin hook: deactivate a user — kills their sessions, retains
|
||||
* their data (POST /api/admin/users/{user_id}/deactivate).
|
||||
*/
|
||||
deactivateUser(user_id: string): Promise<UserProfile>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed auth failure (T-1.10, REQ-69) — thrown by `login` on 401.
|
||||
* The backend answers every failure mode uniformly ("invalid email or
|
||||
* password" — no user enumeration); the client carries the same
|
||||
* honesty through to the login screen.
|
||||
*/
|
||||
export class AuthError extends Error {
|
||||
/** HTTP status of the failed auth request (401 for bad credentials). */
|
||||
readonly status: number;
|
||||
|
||||
constructor(message = "invalid email or password", status = 401) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app is bound to the live backend (auth applies) or to
|
||||
* the mock fixture (dev/test — no login, no redirect; REQ-31/REQ-54).
|
||||
* The login route guard and session bootstrap consult this flag so
|
||||
* mock mode's zero-auth behavior stays byte-for-byte unchanged.
|
||||
*/
|
||||
export const liveMode: boolean =
|
||||
(process.env.NEXT_PUBLIC_RESEARCHX_API_MODE ??
|
||||
process.env.RESEARCHX_API_MODE ??
|
||||
"mock") === "live";
|
||||
|
||||
/**
|
||||
* The app-wide singleton. v0.1: MockApiClient (fixture data + scripted
|
||||
* event replay, zero API keys). v0.2 P3 (REQ-26/REQ-54): the binding
|
||||
* switches to the OpenAPI-generated client in live mode — selected by
|
||||
* NEXT_PUBLIC_RESEARCHX_API_MODE=live (mock stays the default). Live mode covers
|
||||
* the core research loop only (G-002); other surfaces stay mock-backed.
|
||||
* NEXT_PUBLIC_RESEARCHX_API_MODE=live (mock stays the default). Since
|
||||
* v0.3 P3 (D-112) the live client is STANDALONE — every surface
|
||||
* live-bound, zero delegation to the mock.
|
||||
*/
|
||||
class MockApiClientWithLifecycle extends MockApiClient implements RunLifecycle {
|
||||
async pauseRun(id: string): Promise<ResearchRun> {
|
||||
@@ -215,7 +336,7 @@ function resolveBinding(): ApiClient & RunLifecycle {
|
||||
process.env.RESEARCHX_API_MODE ??
|
||||
"mock";
|
||||
if (mode === "live") {
|
||||
return new GeneratedApiClient() as unknown as ApiClient & RunLifecycle;
|
||||
return new GeneratedApiClient();
|
||||
}
|
||||
return new MockApiClientWithLifecycle();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+1077
-4
File diff suppressed because it is too large
Load Diff
@@ -9,15 +9,35 @@ import { describe, expect, it } from "vitest";
|
||||
import { GeneratedApiClient } from "@/lib/api/live-client";
|
||||
import type { BackendRun } from "@/lib/api/test-helpers";
|
||||
|
||||
type RouteHandler = (url: string, init?: RequestInit) => { status: number; body: unknown };
|
||||
type RouteHandler = (
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => { status: number; body: unknown };
|
||||
|
||||
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
const request = input instanceof Request ? input : undefined;
|
||||
const url = request ? request.url : String(input);
|
||||
// openapi-fetch serializes the body onto the Request — surface it
|
||||
// (plus credentials/headers) back to the route handler so tests
|
||||
// can assert the CSRF header and cookie discipline per call.
|
||||
let body: string | undefined;
|
||||
let sentInit: RequestInit | undefined = init;
|
||||
if (request) {
|
||||
const cloned = request.clone();
|
||||
body = cloned.body ? await cloned.text() : undefined;
|
||||
sentInit = {
|
||||
...init,
|
||||
method: request.method,
|
||||
credentials: request.credentials,
|
||||
headers: request.headers,
|
||||
};
|
||||
}
|
||||
for (const [prefix, handler] of Object.entries(routes)) {
|
||||
if (url.startsWith(prefix)) {
|
||||
const { status, body } = handler(url, init);
|
||||
return new Response(JSON.stringify(body), {
|
||||
const { status, body: responseBody } = handler(url, sentInit, body);
|
||||
return new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -27,6 +47,26 @@ function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fet
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
/** Headers actually sent on a call (CSRF + cookie assertions). */
|
||||
function headersOf(init?: RequestInit): Record<string, string> {
|
||||
const raw = init?.headers;
|
||||
const record: Record<string, string> = {};
|
||||
if (
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
typeof (raw as Headers).forEach === "function"
|
||||
) {
|
||||
(raw as Headers).forEach((v, k) => {
|
||||
record[k] = v;
|
||||
});
|
||||
} else if (Array.isArray(raw)) {
|
||||
for (const [k, v] of raw) record[String(k)] = String(v);
|
||||
} else if (raw) {
|
||||
Object.assign(record, raw);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
const backendRun: BackendRun = {
|
||||
id: "abcd2345",
|
||||
status: "awaiting_approval",
|
||||
@@ -54,7 +94,20 @@ describe("GeneratedApiClient (live binding)", () => {
|
||||
cost_estimate: "~5k tokens (estimate)",
|
||||
why_recommended: "Fastest path to a confident cited answer.",
|
||||
output_shape: "memo",
|
||||
dials: { depth: 1, breadth: 1 },
|
||||
dials: {
|
||||
source_universe: "web",
|
||||
search_routing: "single",
|
||||
query_strategy: "decomposed",
|
||||
breadth: 1,
|
||||
depth: 1,
|
||||
quality_gates: "standard",
|
||||
citation_rigor: "per-claim",
|
||||
verification_passes: 1,
|
||||
synthesis_style: "memo",
|
||||
output_structure: "brief",
|
||||
memory_scope: "none",
|
||||
checkpoints: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -65,18 +118,41 @@ describe("GeneratedApiClient (live binding)", () => {
|
||||
expect(cards[0]?.slug).toBe("rapid-answer");
|
||||
expect(cards[0]?.etaMinutes).toBe(2);
|
||||
expect(cards[0]?.costEstimate).toContain("estimate");
|
||||
expect(cards[0]?.defaultDials).toEqual({ depth: 1, breadth: 1 });
|
||||
// Snake_case dials map into the full 12-key frontend DialValues
|
||||
// shape (T-3.8): the Advanced drawer reads every key.
|
||||
expect(cards[0]?.defaultDials).toEqual({
|
||||
sourceUniverse: "web",
|
||||
searchRouting: "single",
|
||||
queryStrategy: "decomposed",
|
||||
breadth: 1,
|
||||
depth: 1,
|
||||
qualityGates: "standard",
|
||||
citationRigor: "per-claim",
|
||||
verificationPasses: 1,
|
||||
synthesisStyle: "memo",
|
||||
outputStructure: "brief",
|
||||
memoryScope: "none",
|
||||
checkpoints: "plan-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates runs through the backend contract", async () => {
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/runs": (url: string, init?: RequestInit) => {
|
||||
const method =
|
||||
init?.method ?? (url.endsWith("/api/runs") ? "POST" : undefined);
|
||||
return {
|
||||
status: 201,
|
||||
body: method === "POST" ? backendRun : { detail: "wrong method" },
|
||||
};
|
||||
"http://127.0.0.1:8000/api/runs": (
|
||||
_url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => {
|
||||
expect(init?.method).toBe("POST");
|
||||
// T-3.8 — the P1-verify flag stays closed: EVERY core-loop
|
||||
// mutation rides credentials + the CSRF header (C3-05).
|
||||
expect(init?.credentials).toBe("include");
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(JSON.parse(body ?? "null")).toMatchObject({
|
||||
question: "How do engines run?",
|
||||
methodology: "rapid-answer",
|
||||
});
|
||||
return { status: 201, body: backendRun };
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
@@ -91,29 +167,29 @@ describe("GeneratedApiClient (live binding)", () => {
|
||||
});
|
||||
|
||||
it("approves, cancels, pauses, resumes via lifecycle routes", async () => {
|
||||
// The discipline holds on EVERY lifecycle mutation, not just the
|
||||
// create — the wrapper is the single injection point (T-3.8).
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
const capture =
|
||||
(status: string) =>
|
||||
(_url: string, init?: RequestInit): { status: number; body: unknown } => {
|
||||
expect(init?.credentials).toBe("include");
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
seen.push({ method: init?.method, status });
|
||||
return { status: 200, body: { ...backendRun, status } };
|
||||
};
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/approve": () => ({
|
||||
status: 200,
|
||||
body: { ...backendRun, status: "searching" },
|
||||
}),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/cancel": () => ({
|
||||
status: 200,
|
||||
body: { ...backendRun, status: "cancelled" },
|
||||
}),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/pause": () => ({
|
||||
status: 200,
|
||||
body: { ...backendRun, status: "searching" },
|
||||
}),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/resume": () => ({
|
||||
status: 200,
|
||||
body: { ...backendRun, status: "verifying" },
|
||||
}),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/approve": capture("searching"),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/cancel": capture("cancelled"),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/pause": capture("searching"),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/resume": capture("verifying"),
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
expect((await client.approvePlan("abcd2345")).status).toBe("searching");
|
||||
expect((await client.cancelRun("abcd2345")).status).toBe("cancelled");
|
||||
expect((await client.pauseRun("abcd2345")).status).toBe("searching");
|
||||
expect((await client.resumeRun("abcd2345")).status).toBe("verifying");
|
||||
expect(seen.map((s) => s.method)).toEqual(["POST", "POST", "POST", "POST"]);
|
||||
});
|
||||
|
||||
it("maps the ledger to frontend citations with health + tier", async () => {
|
||||
|
||||
+917
-105
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* Live-surface boundary tests (T-3.9, REQ-76..81 — the surface swaps).
|
||||
*
|
||||
* Every previously-delegated surface, contract-tested against a
|
||||
* stubbed global fetch (no network, no keys): real routes, cookie
|
||||
* discipline on every call, CSRF header on every mutation (C3-05),
|
||||
* payloads mapped through the SAME frontend types the components
|
||||
* consume (zero rewrites proven — REQ-54).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GeneratedApiClient } from "@/lib/api/live-client";
|
||||
import { MockApiClient } from "@/lib/api/mock/mock-client";
|
||||
|
||||
type RouteHandler = (
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => { status: number; body: unknown };
|
||||
|
||||
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : undefined;
|
||||
const url = request ? request.url : String(input);
|
||||
// openapi-fetch serializes the body onto the Request — surface it
|
||||
// (plus credentials/headers) back to the route handler.
|
||||
let body: string | undefined;
|
||||
let sentInit: RequestInit | undefined = init;
|
||||
if (request) {
|
||||
const cloned = request.clone();
|
||||
body = cloned.body ? await cloned.text() : undefined;
|
||||
sentInit = {
|
||||
...init,
|
||||
method: request.method,
|
||||
credentials: request.credentials,
|
||||
headers: request.headers,
|
||||
};
|
||||
}
|
||||
for (const [prefix, handler] of Object.entries(routes)) {
|
||||
if (url.startsWith(prefix)) {
|
||||
const { status, body: responseBody } = handler(url, sentInit, body);
|
||||
return new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ detail: "not found" }), { status: 404 });
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
/** Headers actually sent on a call (CSRF + cookie assertions). */
|
||||
function headersOf(init?: RequestInit): Record<string, string> {
|
||||
const raw = init?.headers;
|
||||
const record: Record<string, string> = {};
|
||||
if (
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
typeof (raw as Headers).forEach === "function"
|
||||
) {
|
||||
(raw as Headers).forEach((v, k) => {
|
||||
record[k] = v;
|
||||
});
|
||||
} else if (Array.isArray(raw)) {
|
||||
for (const [k, v] of raw) record[String(k)] = String(v);
|
||||
} else if (raw) {
|
||||
Object.assign(record, raw);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
const BASE = "http://127.0.0.1:8000";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Read-model payloads (verbatim shapes from the live app) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LIBRARY_PAYLOAD = {
|
||||
folders: [
|
||||
{
|
||||
id: "deep-report-battery",
|
||||
name: "Deep Report: Battery",
|
||||
description: "Auto-filed from deep report battery research.",
|
||||
},
|
||||
],
|
||||
reports: [
|
||||
{
|
||||
id: "rep-runaaaa1",
|
||||
runId: "runaaaa1",
|
||||
title: "Battery Advances",
|
||||
question: "What about battery research?",
|
||||
methodologySlug: "deep-report",
|
||||
category: "deep-report-battery",
|
||||
generatedAt: "2026-09-20T10:05:00Z",
|
||||
citationCount: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CONNECTIONS_PAYLOAD = [
|
||||
{
|
||||
fromReportId: "rep-runaaaa1",
|
||||
toReportId: "rep-runbbbb1",
|
||||
kind: "shared-sources",
|
||||
label: "2 shared sources",
|
||||
},
|
||||
{
|
||||
fromReportId: "rep-runaaaa1",
|
||||
toReportId: "rep-runcccc1",
|
||||
kind: "related-topic",
|
||||
label: "Adjacent topic",
|
||||
},
|
||||
];
|
||||
|
||||
const GRAPH_PAYLOAD = {
|
||||
nodes: [
|
||||
{
|
||||
id: "runaaaa1",
|
||||
topic: "Battery Advances",
|
||||
linkedSources: ["https://example.org/x"],
|
||||
findings: ["Battery Advances"],
|
||||
},
|
||||
],
|
||||
edges: [{ fromId: "runaaaa1", toId: "runbbbb1", sharedSourceCount: 2 }],
|
||||
};
|
||||
|
||||
const TASKS_PAYLOAD = [
|
||||
{
|
||||
id: "runaaaa1#0",
|
||||
question: "What is the research question?",
|
||||
sourceRunId: "runaaaa1",
|
||||
status: "open",
|
||||
},
|
||||
{
|
||||
id: "runbbbb1#0",
|
||||
question: "An answered question",
|
||||
sourceRunId: "runbbbb1",
|
||||
status: "done",
|
||||
},
|
||||
];
|
||||
|
||||
const SUGGESTIONS_PAYLOAD = [
|
||||
{
|
||||
id: "sug-runcccc1#0",
|
||||
question: "What is the research question?",
|
||||
sourceRunId: "runcccc1",
|
||||
sourceReportTitle: "Deep Report — an in-flight run",
|
||||
},
|
||||
];
|
||||
|
||||
const PROFILE_PAYLOAD = {
|
||||
user_id: "abcd2345",
|
||||
display_name: "Ada Owner",
|
||||
email: "owner@example.org",
|
||||
};
|
||||
|
||||
const CONFIG_PAYLOAD = {
|
||||
resolved_tier: "user",
|
||||
key_store: "enabled",
|
||||
key_status: {
|
||||
configured: true,
|
||||
masked: "…7f2a",
|
||||
disabled: false,
|
||||
reason: null,
|
||||
},
|
||||
model_adapter: "OllamaModelAdapter",
|
||||
search_adapter: "TavilySearchAdapter",
|
||||
storage_dir: "/var/lib/researchx/runs",
|
||||
probe: null,
|
||||
cost_counters: { runs_created: 4, runs_completed: 3 },
|
||||
};
|
||||
|
||||
/** One stub serving every read-model surface at once. */
|
||||
function surfaceRoutes(): Record<string, RouteHandler> {
|
||||
return {
|
||||
[`${BASE}/api/library/connections`]: () => ({
|
||||
status: 200,
|
||||
body: CONNECTIONS_PAYLOAD,
|
||||
}),
|
||||
[`${BASE}/api/library`]: (_url: string, init?: RequestInit) => {
|
||||
expect(init?.credentials).toBe("include");
|
||||
return { status: 200, body: LIBRARY_PAYLOAD };
|
||||
},
|
||||
[`${BASE}/api/graph`]: () => ({ status: 200, body: GRAPH_PAYLOAD }),
|
||||
[`${BASE}/api/tasks`]: () => ({ status: 200, body: TASKS_PAYLOAD }),
|
||||
[`${BASE}/api/suggestions`]: () => ({
|
||||
status: 200,
|
||||
body: SUGGESTIONS_PAYLOAD,
|
||||
}),
|
||||
[`${BASE}/api/profile`]: () => ({ status: 200, body: PROFILE_PAYLOAD }),
|
||||
[`${BASE}/api/config`]: () => ({ status: 200, body: CONFIG_PAYLOAD }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("GeneratedApiClient live surfaces (T-3.9)", () => {
|
||||
it("getReportLibrary maps the camelCase read-model payload 1:1", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const library = await client.getReportLibrary();
|
||||
expect(library.folders).toHaveLength(1);
|
||||
expect(library.folders[0]?.id).toBe("deep-report-battery");
|
||||
expect(library.reports).toHaveLength(1);
|
||||
expect(library.reports[0]).toEqual({
|
||||
id: "rep-runaaaa1",
|
||||
runId: "runaaaa1",
|
||||
title: "Battery Advances",
|
||||
question: "What about battery research?",
|
||||
methodologySlug: "deep-report",
|
||||
category: "deep-report-battery",
|
||||
generatedAt: "2026-09-20T10:05:00Z",
|
||||
citationCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("getReportConnections maps kind + label edges", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const edges = await client.getReportConnections();
|
||||
expect(edges).toEqual([
|
||||
{
|
||||
fromReportId: "rep-runaaaa1",
|
||||
toReportId: "rep-runbbbb1",
|
||||
kind: "shared-sources",
|
||||
label: "2 shared sources",
|
||||
},
|
||||
{
|
||||
fromReportId: "rep-runaaaa1",
|
||||
toReportId: "rep-runcccc1",
|
||||
kind: "related-topic",
|
||||
label: "Adjacent topic",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("getProjectGraph maps nodes + edges", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const graph = await client.getProjectGraph();
|
||||
expect(graph.nodes[0]?.topic).toBe("Battery Advances");
|
||||
expect(graph.nodes[0]?.linkedSources).toEqual(["https://example.org/x"]);
|
||||
expect(graph.edges[0]?.sharedSourceCount).toBe(2);
|
||||
});
|
||||
|
||||
it("getTaskInbox maps derived statuses (G-109: open|done only)", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const inbox = await client.getTaskInbox();
|
||||
expect(inbox).toEqual([
|
||||
{
|
||||
id: "runaaaa1#0",
|
||||
question: "What is the research question?",
|
||||
sourceRunId: "runaaaa1",
|
||||
status: "open",
|
||||
},
|
||||
{
|
||||
id: "runbbbb1#0",
|
||||
question: "An answered question",
|
||||
sourceRunId: "runbbbb1",
|
||||
status: "done",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("getSuggestedQueries maps open items with source titles; honest empty", async () => {
|
||||
const withItems = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const suggestions = await withItems.getSuggestedQueries();
|
||||
expect(suggestions[0]?.sourceReportTitle).toBe("Deep Report — an in-flight run");
|
||||
expect(suggestions[0]?.id).toBe("sug-runcccc1#0");
|
||||
|
||||
// A fresh user gets an honest empty array — never synthetic content.
|
||||
const empty = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
[`${BASE}/api/suggestions`]: () => ({ status: 200, body: [] }),
|
||||
}),
|
||||
);
|
||||
expect(await empty.getSuggestedQueries()).toEqual([]);
|
||||
});
|
||||
|
||||
it("getUserProfile maps GET /api/profile into the DisplayProfile", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const profile = await client.getUserProfile();
|
||||
expect(profile.name).toBe("Ada Owner");
|
||||
expect(profile.initials).toBe("AO");
|
||||
expect(profile.tagline).toBe("owner@example.org");
|
||||
});
|
||||
|
||||
it("getConnectionStatus maps GET /api/config through the five slots (P2-verify closure)", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const slots = await client.getConnectionStatus();
|
||||
expect(slots.map((s) => s.slot)).toEqual([
|
||||
"model",
|
||||
"search",
|
||||
"storage",
|
||||
"memory",
|
||||
"secrets",
|
||||
]);
|
||||
const model = slots[0];
|
||||
expect(model?.status).toBe("connected"); // tier "user" — not keyless
|
||||
expect(model?.resolvedTier).toBe("user");
|
||||
expect(model?.keyStore).toBe("enabled");
|
||||
expect(model?.keyStatus?.masked).toBe("…7f2a");
|
||||
expect(model?.detail).toContain("OllamaModelAdapter");
|
||||
// Storage is server-managed, display-only (D-111).
|
||||
expect(slots[2]?.detail).toContain("/var/lib/researchx/runs");
|
||||
});
|
||||
|
||||
it("getSettings composes the v0.1 shape live from config + catalog", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
...surfaceRoutes(),
|
||||
[`${BASE}/api/methodologies`]: () => ({
|
||||
status: 200,
|
||||
body: [
|
||||
{
|
||||
slug: "rapid-answer",
|
||||
name: "Rapid Answer",
|
||||
description: "One pass, one memo.",
|
||||
eta_minutes: 2,
|
||||
cost_estimate: "~5k tokens (estimate)",
|
||||
why_recommended: "Fastest.",
|
||||
output_shape: "memo",
|
||||
dials: {
|
||||
source_universe: "web",
|
||||
search_routing: "single",
|
||||
query_strategy: "decomposed",
|
||||
breadth: 1,
|
||||
depth: 1,
|
||||
quality_gates: "standard",
|
||||
citation_rigor: "per-claim",
|
||||
verification_passes: 1,
|
||||
synthesis_style: "memo",
|
||||
output_structure: "brief",
|
||||
memory_scope: "none",
|
||||
checkpoints: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "deep-report",
|
||||
name: "Deep Report",
|
||||
description: "Full depth.",
|
||||
eta_minutes: 12,
|
||||
cost_estimate: "~25k tokens (estimate)",
|
||||
why_recommended: "Thorough.",
|
||||
output_shape: "report",
|
||||
dials: {
|
||||
source_universe: "web+academic",
|
||||
search_routing: "adaptive",
|
||||
query_strategy: "decomposed",
|
||||
breadth: 2,
|
||||
depth: 3,
|
||||
quality_gates: "strict",
|
||||
citation_rigor: "per-claim",
|
||||
verification_passes: 2,
|
||||
synthesis_style: "executive",
|
||||
output_structure: "report",
|
||||
memory_scope: "none",
|
||||
checkpoints: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const settings = await client.getSettings();
|
||||
// The recommended card is the first in catalog order here — deep-report
|
||||
// drives the resolved default (index 0 = recommended per the mapping).
|
||||
expect(settings.defaultMethodologySlug).toBe("rapid-answer");
|
||||
expect(settings.storageDirectory).toBe("/var/lib/researchx/runs");
|
||||
// Masked-only key status — never the raw key.
|
||||
expect(settings.apiKeys[0]?.configured).toBe(true);
|
||||
expect(settings.apiKeys[0]?.maskedValue).toBe("…7f2a");
|
||||
expect(settings.theme).toBe("paper");
|
||||
});
|
||||
|
||||
it("saveSettings answers with the authoritative server read (D-111)", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
...surfaceRoutes(),
|
||||
[`${BASE}/api/methodologies`]: () => ({
|
||||
status: 200,
|
||||
body: [
|
||||
{
|
||||
slug: "rapid-answer",
|
||||
name: "Rapid Answer",
|
||||
description: "One pass, one memo.",
|
||||
eta_minutes: 2,
|
||||
cost_estimate: "~5k tokens (estimate)",
|
||||
why_recommended: "Fastest.",
|
||||
output_shape: "memo",
|
||||
dials: {
|
||||
source_universe: "web",
|
||||
search_routing: "single",
|
||||
query_strategy: "decomposed",
|
||||
breadth: 1,
|
||||
depth: 1,
|
||||
quality_gates: "standard",
|
||||
citation_rigor: "per-claim",
|
||||
verification_passes: 1,
|
||||
synthesis_style: "memo",
|
||||
output_structure: "brief",
|
||||
memory_scope: "none",
|
||||
checkpoints: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// The server owns every v0.1 field live — a save is a re-read,
|
||||
// never a client-side pretend-persist.
|
||||
const saved = await client.saveSettings({ storageDirectory: "~/elsewhere" });
|
||||
expect(saved.storageDirectory).toBe("/var/lib/researchx/runs");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Refine — real POSTs, honest 503 (T-3.6/T-3.9) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("GeneratedApiClient refine (live binding, REQ-80)", () => {
|
||||
it("getRefinementQuestions POSTs the draft with CSRF + credentials", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
[`${BASE}/api/refine/questions`]: (
|
||||
_url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => {
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
const parsed = JSON.parse(body ?? "null") as { draft: string };
|
||||
expect(parsed.draft).toBe("How do batteries work?");
|
||||
return { status: 200, body: { questions: ["Which scope?", "What decision?"] } };
|
||||
},
|
||||
}),
|
||||
);
|
||||
const questions = await client.getRefinementQuestions("How do batteries work?");
|
||||
expect(questions).toHaveLength(2);
|
||||
expect(questions[0]?.prompt).toBe("Which scope?");
|
||||
expect(questions[0]?.optional).toBe(false); // the first question is required
|
||||
expect(questions[1]?.optional).toBe(true); // the rest are skippable
|
||||
});
|
||||
|
||||
it("getRefinementQuestions 503 → honest error, never a hang, never synthetic", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
[`${BASE}/api/refine/questions`]: () => ({
|
||||
status: 503,
|
||||
body: { detail: "no local endpoint and no key — tier unavailable" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(client.getRefinementQuestions("draft")).rejects.toThrow(
|
||||
/refine is unavailable/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("composeRefinedQuestion POSTs ordered answers with CSRF + credentials", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
[`${BASE}/api/refine/compose`]: (
|
||||
_url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => {
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
const parsed = JSON.parse(body ?? "null") as {
|
||||
draft: string;
|
||||
answers: string[];
|
||||
};
|
||||
// Answers travel in exchange order, trimmed.
|
||||
expect(parsed.draft).toBe("How do batteries work?");
|
||||
expect(parsed.answers).toEqual(["automotive", "supplier choice", ""]);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
question: "How do batteries work? — automotive focus",
|
||||
context: "Feeds a supplier choice.",
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
const refined = await client.composeRefinedQuestion("How do batteries work?", {
|
||||
"q-1": "automotive",
|
||||
"q-2": "supplier choice",
|
||||
"q-3": "",
|
||||
});
|
||||
expect(refined.question).toBe("How do batteries work? — automotive focus");
|
||||
expect(refined.context).toBe("Feeds a supplier choice.");
|
||||
});
|
||||
|
||||
it("composeRefinedQuestion 503 → honest error", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
[`${BASE}/api/refine/compose`]: () => ({
|
||||
status: 503,
|
||||
body: { detail: "planner unreachable" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client.composeRefinedQuestion("draft", { "q-1": "answer" }),
|
||||
).rejects.toThrow(/refine is unavailable/i);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* D-112: the delegation is DEAD — structural proof */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("GeneratedApiClient standalone (D-112)", () => {
|
||||
it("has no MockApiClient in its prototype chain — the delegation is gone", () => {
|
||||
const client = new GeneratedApiClient();
|
||||
expect(client).not.toBeInstanceOf(MockApiClient);
|
||||
let proto = Object.getPrototypeOf(client);
|
||||
while (proto !== null) {
|
||||
expect(proto.constructor.name).not.toBe("MockApiClient");
|
||||
proto = Object.getPrototypeOf(proto);
|
||||
}
|
||||
});
|
||||
|
||||
it("implements every ApiClient surface as its own methods", () => {
|
||||
const client = new GeneratedApiClient();
|
||||
const required = [
|
||||
"getMethodologies",
|
||||
"createRun",
|
||||
"getRun",
|
||||
"approvePlan",
|
||||
"cancelRun",
|
||||
"pauseRun",
|
||||
"resumeRun",
|
||||
"getRunEvents",
|
||||
"getReport",
|
||||
"getLedger",
|
||||
"exportReport",
|
||||
"getProjectGraph",
|
||||
"getTaskInbox",
|
||||
"getReportLibrary",
|
||||
"getReportConnections",
|
||||
"getUserProfile",
|
||||
"getConnectionStatus",
|
||||
"getSettings",
|
||||
"saveSettings",
|
||||
"getUserSettings",
|
||||
"saveUserSettings",
|
||||
"setModelKey",
|
||||
"getConfigStatus",
|
||||
"getSuggestedQueries",
|
||||
"getRefinementQuestions",
|
||||
"composeRefinedQuestion",
|
||||
"login",
|
||||
"logout",
|
||||
"getSession",
|
||||
"listUsers",
|
||||
"createUser",
|
||||
"deactivateUser",
|
||||
] as const;
|
||||
for (const method of required) {
|
||||
expect(
|
||||
typeof (client as unknown as Record<string, unknown>)[method],
|
||||
).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { ApiClient, CreateRunInput } from "@/lib/api/client";
|
||||
import { AuthError } from "@/lib/api/client";
|
||||
import {
|
||||
buildSuggestedQueries,
|
||||
composeRefinedQuestion,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
reportConnectionsFixture,
|
||||
reportLibraryFixture,
|
||||
settingsFixture,
|
||||
userSettingsFixture,
|
||||
userProfileFixture,
|
||||
} from "@/lib/api/mock/shell-fixtures";
|
||||
import {
|
||||
@@ -32,9 +34,13 @@ import {
|
||||
} from "@/lib/api/mock/scripted-run";
|
||||
import type {
|
||||
CitationLedger,
|
||||
ConfigStatus,
|
||||
ConnectionSlot,
|
||||
CreateUserInput,
|
||||
DisplayProfile,
|
||||
InboxItem,
|
||||
Methodology,
|
||||
ModelKeyStatus,
|
||||
ProjectGraph,
|
||||
RefinementQuestion,
|
||||
RefinedQuestion,
|
||||
@@ -45,6 +51,7 @@ import type {
|
||||
Settings,
|
||||
SuggestedQuery,
|
||||
TraceEvent,
|
||||
UserSettings,
|
||||
UserProfile,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -274,7 +281,7 @@ export class MockApiClient implements ApiClient {
|
||||
}
|
||||
|
||||
/** The local single-user profile behind the avatar (REQ-66). */
|
||||
async getUserProfile(): Promise<UserProfile> {
|
||||
async getUserProfile(): Promise<DisplayProfile> {
|
||||
return structuredClone(userProfileFixture);
|
||||
}
|
||||
|
||||
@@ -308,6 +315,74 @@ export class MockApiClient implements ApiClient {
|
||||
/** Session-persisted settings (mock-local). */
|
||||
#settings: Settings = structuredClone(settingsFixture);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* v0.3 P2 per-user settings + model keys (T-2.9, REQ-72..74). */
|
||||
/* The mock is an honest zero-backend fixture: it mirrors the */
|
||||
/* backend's DEFAULT-disabled key store (no KEK in mock mode) so */
|
||||
/* the UI's degraded states are exercised, not hidden. */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/** Per-user settings — starts from the backend's fresh-user defaults. */
|
||||
#userSettings: UserSettings = structuredClone(userSettingsFixture);
|
||||
|
||||
/** In-memory masked key state — the raw key is NEVER stored. */
|
||||
#modelKey: ModelKeyStatus = {
|
||||
configured: false,
|
||||
masked: null,
|
||||
disabled: false,
|
||||
reason: null,
|
||||
};
|
||||
|
||||
/** Per-user settings (REQ-72) — session-local, defaults for fresh users. */
|
||||
async getUserSettings(): Promise<UserSettings> {
|
||||
return structuredClone(this.#userSettings);
|
||||
}
|
||||
|
||||
/** Persist a partial per-user settings update (REQ-72) — in-memory. */
|
||||
async saveUserSettings(update: Partial<UserSettings>): Promise<UserSettings> {
|
||||
const next: UserSettings = {
|
||||
...this.#userSettings,
|
||||
...structuredClone(update),
|
||||
};
|
||||
this.#userSettings = next;
|
||||
return structuredClone(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the raw model key (REQ-73): the mock keeps ONLY the masked
|
||||
* tail in memory — the raw value is accepted, masked, and dropped.
|
||||
* Mirrors the backend's mask shape ("…" + last 4).
|
||||
*/
|
||||
async setModelKey(key: string): Promise<ModelKeyStatus> {
|
||||
const masked = `…${key.slice(-4)}`;
|
||||
this.#modelKey = {
|
||||
configured: true,
|
||||
masked: key.length >= 4 ? masked : "…",
|
||||
disabled: false,
|
||||
reason: null,
|
||||
};
|
||||
return structuredClone(this.#modelKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Honest mock config status (REQ-74): the mock has no per-user key
|
||||
* store (zero-backend fixture), so resolved_tier degrades to
|
||||
* "keyless" — the documented keyless-localhost tier — and key_store
|
||||
* reports "disabled" exactly like a backend without a KEK.
|
||||
*/
|
||||
async getConfigStatus(): Promise<ConfigStatus> {
|
||||
return {
|
||||
resolved_tier: "keyless",
|
||||
key_store: "disabled",
|
||||
key_status: structuredClone(this.#modelKey),
|
||||
model_adapter: "FakeModelAdapter",
|
||||
search_adapter: "FakeSearchAdapter",
|
||||
storage_dir: settingsFixture.storageDirectory,
|
||||
probe: null,
|
||||
cost_counters: { runs_created: 0, runs_completed: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow-up suggestions from unanswered open questions (REQ-67).
|
||||
* Derived live from the inbox fixtures — a completed item drops
|
||||
@@ -334,6 +409,92 @@ export class MockApiClient implements ApiClient {
|
||||
return composeRefinedQuestion(draft, answers);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* v0.3 P1 session surface — mock-local (T-1.10, REQ-69/REQ-70). */
|
||||
/* The mock stays a standalone zero-backend fixture: login accepts */
|
||||
/* any non-empty credentials and swaps an in-memory session in; */
|
||||
/* the dev user mirrors the backend's synthetic dev admin */
|
||||
/* (DEV_USER_ID "dev00000", guard.py). */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
#session: UserProfile | null = null;
|
||||
|
||||
/** The mock dev user — same synthetic admin the backend's dev guard resolves. */
|
||||
static readonly DEV_USER: UserProfile = {
|
||||
user_id: "dev00000",
|
||||
email: "dev@localhost",
|
||||
display_name: "Dev User",
|
||||
role: "admin",
|
||||
active: true,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
/** Small fixture user set for the admin hooks (REQ-70). */
|
||||
static readonly USERS: UserProfile[] = [
|
||||
MockApiClient.DEV_USER,
|
||||
{
|
||||
user_id: "abcd2345",
|
||||
email: "colleague@example.org",
|
||||
display_name: "Fixture Colleague",
|
||||
role: "user",
|
||||
active: true,
|
||||
created_at: "2026-02-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** Local "login" — any non-empty credentials open the dev session. */
|
||||
async login(email: string, password: string): Promise<UserProfile> {
|
||||
if (email.trim() === "" || password === "") {
|
||||
throw new AuthError();
|
||||
}
|
||||
this.#session = structuredClone(MockApiClient.DEV_USER);
|
||||
return structuredClone(MockApiClient.DEV_USER);
|
||||
}
|
||||
|
||||
/** Local "logout" — clears the in-memory session. */
|
||||
async logout(): Promise<void> {
|
||||
this.#session = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mock session is implicitly open (dev fixture): the dev user
|
||||
* when a "login" happened this session, otherwise still the dev
|
||||
* user — the mock never gates the app behind a login (REQ-31).
|
||||
*/
|
||||
async getSession(): Promise<UserProfile | null> {
|
||||
return structuredClone(this.#session ?? MockApiClient.DEV_USER);
|
||||
}
|
||||
|
||||
/** Admin hooks — small fixture arrays (REQ-70). */
|
||||
async listUsers(): Promise<UserProfile[]> {
|
||||
return structuredClone(MockApiClient.USERS);
|
||||
}
|
||||
|
||||
async createUser(input: CreateUserInput): Promise<UserProfile> {
|
||||
const created: UserProfile = {
|
||||
user_id: "newu0001",
|
||||
email: input.email,
|
||||
display_name: input.display_name ?? input.email.split("@")[0] ?? "New User",
|
||||
role: input.role ?? "user",
|
||||
active: true,
|
||||
created_at: "2026-09-29T00:00:00Z",
|
||||
};
|
||||
MockApiClient.USERS.push(created);
|
||||
return structuredClone(created);
|
||||
}
|
||||
|
||||
async deactivateUser(user_id: string): Promise<UserProfile> {
|
||||
const user = MockApiClient.USERS.find((u) => u.user_id === user_id);
|
||||
if (!user) {
|
||||
throw new Error(`mock: unknown user ${user_id}`);
|
||||
}
|
||||
user.active = false;
|
||||
if (this.#session?.user_id === user_id) {
|
||||
this.#session = null; // deactivation kills sessions
|
||||
}
|
||||
return structuredClone(user);
|
||||
}
|
||||
|
||||
/** The scripted run for the prototype's demo question. */
|
||||
#defaultRun(): ResearchRun {
|
||||
return buildScriptedRun({
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
* masked API-key settings, adapter-slot connection status, and the local
|
||||
* single-user profile. Clearly example content, consistent with the
|
||||
* battery-research domain used across the prototype fixtures.
|
||||
*
|
||||
* v0.3 P2 (T-2.9): adds the per-user settings fixture (the backend's
|
||||
* fresh-user defaults — planner/writer "gpt-oss:20b", appearance
|
||||
* "system") and the honest keyless/degraded credential fields on the
|
||||
* model connection slot (resolvedTier "keyless", keyStore "disabled" —
|
||||
* the mock has no key store, exactly like a backend without a KEK).
|
||||
*/
|
||||
|
||||
import type { Settings } from "@/lib/types";
|
||||
import type { Settings, UserSettings } from "@/lib/types";
|
||||
import type {
|
||||
DisplayProfile,
|
||||
ReportConnection,
|
||||
ReportLibrary,
|
||||
UserProfile,
|
||||
} from "@/lib/types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -157,6 +163,19 @@ export const settingsFixture: Settings = {
|
||||
theme: "paper",
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-user settings fixture (P2, REQ-72) — mirrors the backend's
|
||||
* fresh-user defaults exactly (auth/settings.py: planner/writer
|
||||
* "gpt-oss:20b", appearance "system"). The mock has no per-user
|
||||
* persistence across sessions; this is the starting payload.
|
||||
*/
|
||||
export const userSettingsFixture: UserSettings = {
|
||||
planner_model: "gpt-oss:20b",
|
||||
writer_model: "gpt-oss:20b",
|
||||
methodology_defaults: {},
|
||||
appearance: "system",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Connection status (REQ-65 — adapter-slot health) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -169,6 +188,18 @@ export const connectionStatusFixture: ConnectionSlot[] = [
|
||||
label: "Model",
|
||||
status: "connected",
|
||||
detail: "Ollama cloud — key detected",
|
||||
// P2 (REQ-74) honest mock: the zero-backend fixture has no
|
||||
// per-user key store and no server key, so the tier degrades to
|
||||
// keyless localhost — the documented keyless path, not a lie.
|
||||
resolvedTier: "keyless",
|
||||
keyStore: "disabled",
|
||||
keyStatus: {
|
||||
configured: false,
|
||||
masked: null,
|
||||
disabled: true,
|
||||
reason:
|
||||
"mock fixture: no key store (zero-backend mode) — the credential chain stays keyless",
|
||||
},
|
||||
},
|
||||
{
|
||||
slot: "search",
|
||||
@@ -200,7 +231,7 @@ export const connectionStatusFixture: ConnectionSlot[] = [
|
||||
/* User profile (REQ-66) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const userProfileFixture: UserProfile = {
|
||||
export const userProfileFixture: DisplayProfile = {
|
||||
name: "Researcher",
|
||||
initials: "RS",
|
||||
tagline: "Local workspace — single user",
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Session-surface boundary tests (T-1.10, REQ-69, REQ-70).
|
||||
*
|
||||
* The v0.3 P1 ApiClient additions — login / logout / getSession plus
|
||||
* the admin hooks — contract-tested against BOTH bindings:
|
||||
* - the mock binding (standalone, zero backend — REQ-31/REQ-54), and
|
||||
* - the live binding against a stubbed global fetch (no network, no
|
||||
* keys), asserting the real routes, the CSRF header on every
|
||||
* mutation (C3-05), and credentials:"include" cookie discipline.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AuthError, type ApiClient } from "@/lib/api/client";
|
||||
import { MockApiClient } from "@/lib/api/mock/mock-client";
|
||||
import { GeneratedApiClient } from "@/lib/api/live-client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock binding — standalone session fixture */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("MockApiClient session surface (REQ-69/REQ-70)", () => {
|
||||
it("login accepts any non-empty credentials and returns the dev admin", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const user = await client.login("dev@localhost", "anything");
|
||||
expect(user.user_id).toBe("dev00000");
|
||||
expect(user.role).toBe("admin");
|
||||
expect(user.active).toBe(true);
|
||||
});
|
||||
|
||||
it("getSession returns the dev user (mock is never login-gated)", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const session = await client.getSession();
|
||||
expect(session?.user_id).toBe("dev00000");
|
||||
});
|
||||
|
||||
it("logout clears the session; getSession still reports the dev fixture", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
await client.login("dev@localhost", "pw");
|
||||
await client.logout();
|
||||
// The mock dev user persists as the implicit session (REQ-31)…
|
||||
expect((await client.getSession())?.user_id).toBe("dev00000");
|
||||
});
|
||||
|
||||
it("login rejects empty credentials with a typed AuthError", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
await expect(client.login("", "")).rejects.toBeInstanceOf(AuthError);
|
||||
});
|
||||
|
||||
it("admin hooks return small fixture arrays and honor deactivate", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const users = await client.listUsers();
|
||||
expect(users.length).toBeGreaterThanOrEqual(2);
|
||||
expect(users[0]?.role).toBe("admin");
|
||||
|
||||
const created = await client.createUser({
|
||||
email: "new@example.org",
|
||||
password: "not-a-real-secret",
|
||||
});
|
||||
expect(created.email).toBe("new@example.org");
|
||||
expect(created.active).toBe(true);
|
||||
|
||||
const deactivated = await client.deactivateUser(created.user_id);
|
||||
expect(deactivated.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Live binding — real routes against a stubbed fetch */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type RouteHandler = (url: string, init?: RequestInit) => { status: number; body: unknown };
|
||||
|
||||
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
|
||||
// openapi-fetch builds a Request and hands it to fetch — surface the
|
||||
// request's own headers + credentials back to the route handler so
|
||||
// tests can assert the CSRF header and cookie discipline per call.
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : undefined;
|
||||
const url = request ? request.url : String(input);
|
||||
const sentInit: RequestInit | undefined = request
|
||||
? {
|
||||
...init,
|
||||
method: request.method,
|
||||
credentials: request.credentials,
|
||||
headers: request.headers,
|
||||
}
|
||||
: init;
|
||||
for (const [prefix, handler] of Object.entries(routes)) {
|
||||
if (url.startsWith(prefix)) {
|
||||
const { status, body } = handler(url, sentInit);
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify(body404()), { status: 404 });
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
const body404 = (): unknown => ({ detail: "not found" });
|
||||
|
||||
const backendUser = {
|
||||
user_id: "abcd2345",
|
||||
email: "owner@example.org",
|
||||
display_name: "Owner",
|
||||
role: "admin",
|
||||
active: true,
|
||||
created_at: "2026-09-29T12:00:00Z",
|
||||
};
|
||||
|
||||
/** Headers actually sent on the last mutating call (CSRF + cookie assertions). */
|
||||
function mutationCapture(): {
|
||||
headersOf: (init?: RequestInit) => Record<string, string>;
|
||||
} {
|
||||
return {
|
||||
// Duck-typed: jsdom + Node fetch disagree on the Headers realm, so
|
||||
// `instanceof Headers` is false across the boundary — check shape.
|
||||
headersOf: (init?: RequestInit) => {
|
||||
const raw = init?.headers;
|
||||
const record: Record<string, string> = {};
|
||||
if (
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
typeof (raw as Headers).forEach === "function"
|
||||
) {
|
||||
(raw as Headers).forEach((v, k) => {
|
||||
record[k] = v;
|
||||
});
|
||||
} else if (Array.isArray(raw)) {
|
||||
for (const [k, v] of raw) record[String(k)] = String(v);
|
||||
} else if (raw) {
|
||||
Object.assign(record, raw);
|
||||
}
|
||||
return record;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("GeneratedApiClient session surface (live binding, REQ-69/REQ-70)", () => {
|
||||
it("login POSTs /api/auth/login with the CSRF header + credentials", async () => {
|
||||
const { headersOf } = mutationCapture();
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/auth/login": (_url: string, init?: RequestInit) => {
|
||||
const headers = headersOf(init);
|
||||
expect(headers["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
return { status: 200, body: backendUser };
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
const user = await client.login("owner@example.org", "correct-horse");
|
||||
expect(user.user_id).toBe("abcd2345");
|
||||
expect(user.email).toBe("owner@example.org");
|
||||
expect(user.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("login 401 → typed AuthError with the uniform message", async () => {
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/auth/login": () => ({
|
||||
status: 401,
|
||||
body: { detail: "invalid email or password" },
|
||||
}),
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
const error = await client.login("owner@example.org", "wrong").catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(AuthError);
|
||||
expect((error as AuthError).status).toBe(401);
|
||||
expect((error as AuthError).message).toBe("invalid email or password");
|
||||
});
|
||||
|
||||
it("logout POSTs /api/auth/logout with the CSRF header", async () => {
|
||||
const { headersOf } = mutationCapture();
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/auth/logout": (_url: string, init?: RequestInit) => {
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
return { status: 200, body: { ok: true } };
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
await expect(client.logout()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("getSession maps 200 → UserProfile; 401 → null (never a throw)", async () => {
|
||||
const ok = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
"http://127.0.0.1:8000/api/auth/me": () => ({
|
||||
status: 200,
|
||||
body: backendUser,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect((await ok.getSession())?.user_id).toBe("abcd2345");
|
||||
|
||||
const unauthed = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
"http://127.0.0.1:8000/api/auth/me": () => ({
|
||||
status: 401,
|
||||
body: { detail: "authentication required" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(await unauthed.getSession()).toBeNull();
|
||||
});
|
||||
|
||||
it("admin hooks hit the protected routes with CSRF on mutations", async () => {
|
||||
const { headersOf } = mutationCapture();
|
||||
const routes: Record<string, RouteHandler> = {
|
||||
// Registered BEFORE the collection route: the deactivate path
|
||||
// startswith-matches "/api/admin/users" too.
|
||||
"http://127.0.0.1:8000/api/admin/users/abcd2345/deactivate": (
|
||||
_url: string,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
return { status: 200, body: { ...backendUser, active: false } };
|
||||
},
|
||||
"http://127.0.0.1:8000/api/admin/users": (url: string, init?: RequestInit) => {
|
||||
if (url.endsWith("/api/admin/users") && init?.method !== "POST") {
|
||||
return { status: 200, body: [backendUser] };
|
||||
}
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
...backendUser,
|
||||
user_id: "newaaa01",
|
||||
email: "second@example.org",
|
||||
role: "user",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
|
||||
const users = await client.listUsers();
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0]?.user_id).toBe("abcd2345");
|
||||
|
||||
const created = await client.createUser({
|
||||
email: "second@example.org",
|
||||
password: "not-a-real-secret",
|
||||
role: "user",
|
||||
});
|
||||
expect(created.user_id).toBe("newaaa01");
|
||||
expect(created.role).toBe("user");
|
||||
|
||||
const deactivated = await client.deactivateUser("abcd2345");
|
||||
expect(deactivated.active).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Per-user settings + model-key boundary tests (T-2.9, REQ-72/73/74).
|
||||
*
|
||||
* The v0.3 P2 ApiClient additions — getUserSettings / saveUserSettings /
|
||||
* setModelKey / getConfigStatus — contract-tested against BOTH bindings:
|
||||
* - the mock binding: honest zero-backend fixture (fresh-user defaults,
|
||||
* in-memory masked key state, keyless tier, key store "disabled"), and
|
||||
* - the live binding against a stubbed global fetch (no network, no
|
||||
* keys): real routes, CSRF header on every mutation (C3-05),
|
||||
* credentials:"include" cookie discipline, masked-only key echo,
|
||||
* and the honest 409 KeyStoreDisabledError refusal.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// client.ts first (runtime import — it binds MockApiClient before the
|
||||
// live client extends it); KeyStoreDisabledError is consumed through
|
||||
// the boundary like AuthError in the session tests.
|
||||
import { KeyStoreDisabledError, type ApiClient } from "@/lib/api/client";
|
||||
import { MockApiClient } from "@/lib/api/mock/mock-client";
|
||||
import { GeneratedApiClient } from "@/lib/api/live-client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock binding — standalone per-user fixture */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("MockApiClient per-user settings surface (T-2.9)", () => {
|
||||
it("getUserSettings returns the fresh-user defaults (planner/writer gpt-oss:20b)", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const settings = await client.getUserSettings();
|
||||
expect(settings.planner_model).toBe("gpt-oss:20b");
|
||||
expect(settings.writer_model).toBe("gpt-oss:20b");
|
||||
expect(settings.methodology_defaults).toEqual({});
|
||||
expect(settings.appearance).toBe("system");
|
||||
});
|
||||
|
||||
it("saveUserSettings updates in-memory state and round-trips", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const saved = await client.saveUserSettings({
|
||||
planner_model: "gpt-oss:120b",
|
||||
writer_model: "llama3.1:70b",
|
||||
});
|
||||
expect(saved.planner_model).toBe("gpt-oss:120b");
|
||||
expect(saved.writer_model).toBe("llama3.1:70b");
|
||||
// Fresh read sees the persisted payload.
|
||||
const reread = await client.getUserSettings();
|
||||
expect(reread.planner_model).toBe("gpt-oss:120b");
|
||||
expect(reread.writer_model).toBe("llama3.1:70b");
|
||||
});
|
||||
|
||||
it("saveUserSettings keeps unset fields (partial update semantics)", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const saved = await client.saveUserSettings({ planner_model: "mistral:small" });
|
||||
// writer_model was not in the update — the default survives.
|
||||
expect(saved.planner_model).toBe("mistral:small");
|
||||
expect(saved.writer_model).toBe("gpt-oss:20b");
|
||||
});
|
||||
|
||||
it("setModelKey stores ONLY the masked tail in memory — never the raw key", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const status = await client.setModelKey("sk-live-raw-secret-7f2a");
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.masked).toBe("…7f2a");
|
||||
expect(status.disabled).toBe(false);
|
||||
// getConfigStatus reflects the stored masked state — the raw key
|
||||
// never appears in ANY response the boundary produces.
|
||||
const config = await client.getConfigStatus();
|
||||
expect(config.key_status.configured).toBe(true);
|
||||
expect(config.key_status.masked).toBe("…7f2a");
|
||||
expect(JSON.stringify(config)).not.toContain("sk-live-raw-secret-7f2a");
|
||||
});
|
||||
|
||||
it("getConfigStatus is honest: keyless tier, disabled key store", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const config = await client.getConfigStatus();
|
||||
// The mock has no key store and no server key — the honest fixture.
|
||||
expect(config.resolved_tier).toBe("keyless");
|
||||
expect(config.key_store).toBe("disabled");
|
||||
expect(config.cost_counters).toEqual({ runs_created: 0, runs_completed: 0 });
|
||||
expect(config.storage_dir).toBeTruthy();
|
||||
});
|
||||
|
||||
it("connection status carries the per-user tier extension on the model slot", async () => {
|
||||
const client: ApiClient = new MockApiClient();
|
||||
const slots = await client.getConnectionStatus();
|
||||
const model = slots.find((s) => s.slot === "model");
|
||||
expect(model?.resolvedTier).toBe("keyless");
|
||||
expect(model?.keyStore).toBe("disabled");
|
||||
expect(model?.keyStatus?.disabled).toBe(true);
|
||||
expect(model?.keyStatus?.reason).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Live binding — real routes against a stubbed fetch */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type RouteHandler = (
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => { status: number; body: unknown };
|
||||
|
||||
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : undefined;
|
||||
const url = request ? request.url : String(input);
|
||||
// openapi-fetch serializes the body onto the Request object —
|
||||
// surface it (plus credentials/headers) back to the route handler
|
||||
// so the assertions see exactly what went over the wire.
|
||||
// request.body is a ReadableStream — read it back out.
|
||||
let body: string | undefined;
|
||||
let sentInit: RequestInit | undefined = init;
|
||||
if (request) {
|
||||
const cloned = request.clone();
|
||||
body = cloned.body ? await cloned.text() : undefined;
|
||||
sentInit = {
|
||||
...init,
|
||||
method: request.method,
|
||||
credentials: request.credentials,
|
||||
headers: request.headers,
|
||||
};
|
||||
}
|
||||
for (const [prefix, handler] of Object.entries(routes)) {
|
||||
if (url.startsWith(prefix)) {
|
||||
const { status, body: responseBody } = handler(url, sentInit, body);
|
||||
return new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ detail: "not found" }), { status: 404 });
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
/** Headers actually sent on a call (CSRF + cookie assertions). */
|
||||
function headersOf(init?: RequestInit): Record<string, string> {
|
||||
const raw = init?.headers;
|
||||
const record: Record<string, string> = {};
|
||||
if (
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
typeof (raw as Headers).forEach === "function"
|
||||
) {
|
||||
(raw as Headers).forEach((v, k) => {
|
||||
record[k] = v;
|
||||
});
|
||||
} else if (Array.isArray(raw)) {
|
||||
for (const [k, v] of raw) record[String(k)] = String(v);
|
||||
} else if (raw) {
|
||||
Object.assign(record, raw);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
const backendUserSettings = {
|
||||
planner_model: "gpt-oss:20b",
|
||||
writer_model: "gpt-oss:20b",
|
||||
methodology_defaults: {},
|
||||
appearance: "system",
|
||||
};
|
||||
|
||||
const backendKeyStatus = {
|
||||
configured: true,
|
||||
masked: "…7f2a",
|
||||
disabled: false,
|
||||
reason: null,
|
||||
};
|
||||
|
||||
const backendConfigStatus = {
|
||||
resolved_tier: "user",
|
||||
key_store: "enabled",
|
||||
key_status: backendKeyStatus,
|
||||
model_adapter: "OllamaModelAdapter",
|
||||
search_adapter: "TavilySearchAdapter",
|
||||
storage_dir: "/var/lib/researchx/runs",
|
||||
probe: { endpoint: "https://ollama.com/v1", structured_outputs_supported: true },
|
||||
cost_counters: { runs_created: 4, runs_completed: 3 },
|
||||
};
|
||||
|
||||
describe("GeneratedApiClient per-user settings surface (live binding, T-2.9)", () => {
|
||||
it("getUserSettings hits GET /api/config/settings with cookie discipline", async () => {
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/config/settings": (_url: string, init?: RequestInit) => {
|
||||
// GET, never a mutation — but the cookie still rides along.
|
||||
expect(init?.credentials).toBe("include");
|
||||
return { status: 200, body: backendUserSettings };
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
const settings = await client.getUserSettings();
|
||||
expect(settings.planner_model).toBe("gpt-oss:20b");
|
||||
expect(settings.appearance).toBe("system");
|
||||
});
|
||||
|
||||
it("saveUserSettings PUTs a partial body with the CSRF header", async () => {
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/config/settings": (
|
||||
_url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => {
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
// Partial body: ONLY the field the caller set travels.
|
||||
const parsed = JSON.parse(body ?? "null") as Record<string, unknown>;
|
||||
expect(Object.keys(parsed)).toEqual(["planner_model"]);
|
||||
return {
|
||||
status: 200,
|
||||
body: { ...backendUserSettings, planner_model: "gpt-oss:120b" },
|
||||
};
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
const saved = await client.saveUserSettings({ planner_model: "gpt-oss:120b" });
|
||||
expect(saved.planner_model).toBe("gpt-oss:120b");
|
||||
});
|
||||
|
||||
it("setModelKey PUTs the raw key once and resolves ONLY the masked status", async () => {
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/config/settings/model-key": (
|
||||
_url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => {
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
const parsed = JSON.parse(body ?? "null") as { key: string };
|
||||
expect(parsed.key).toBe("sk-live-raw-secret-7f2a");
|
||||
// The response is masked-only — the raw key never echoes.
|
||||
return { status: 200, body: backendKeyStatus };
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
const status = await client.setModelKey("sk-live-raw-secret-7f2a");
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.masked).toBe("…7f2a");
|
||||
expect(JSON.stringify(status)).not.toContain("sk-live-raw-secret-7f2a");
|
||||
});
|
||||
|
||||
it("setModelKey surfaces a disabled key store as a typed 409 with the honest reason", async () => {
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/config/settings/model-key": () => ({
|
||||
status: 409,
|
||||
body: {
|
||||
detail: {
|
||||
key_store: "disabled",
|
||||
reason:
|
||||
"no RESEARCHX_KEK configured (env-file secrets 'keys' scope); per-user model keys cannot be stored",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
const error = await client
|
||||
.setModelKey("sk-anything")
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(error).toBeInstanceOf(KeyStoreDisabledError);
|
||||
const typed = error as KeyStoreDisabledError;
|
||||
expect(typed.reason).toContain("RESEARCHX_KEK");
|
||||
// Honest refusal — never a silent no-op, never the raw key.
|
||||
expect(JSON.stringify(typed)).not.toContain("sk-anything");
|
||||
});
|
||||
|
||||
it("getConfigStatus maps the full config payload to the frontend shape", async () => {
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/config": (_url: string, init?: RequestInit) => {
|
||||
expect(init?.credentials).toBe("include");
|
||||
return { status: 200, body: backendConfigStatus };
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
const config = await client.getConfigStatus();
|
||||
expect(config.resolved_tier).toBe("user");
|
||||
expect(config.key_store).toBe("enabled");
|
||||
expect(config.key_status.masked).toBe("…7f2a");
|
||||
expect(config.model_adapter).toBe("OllamaModelAdapter");
|
||||
expect(config.storage_dir).toBe("/var/lib/researchx/runs");
|
||||
expect(config.probe).not.toBeNull();
|
||||
expect(config.cost_counters).toEqual({ runs_created: 4, runs_completed: 3 });
|
||||
});
|
||||
|
||||
it("getConfigStatus tolerates a keyless degraded backend honestly", async () => {
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/config": () => ({
|
||||
status: 200,
|
||||
body: {
|
||||
resolved_tier: "keyless",
|
||||
key_store: "disabled",
|
||||
key_status: {
|
||||
configured: false,
|
||||
masked: null,
|
||||
disabled: true,
|
||||
reason: "no RESEARCHX_KEK configured",
|
||||
},
|
||||
model_adapter: "OllamaModelAdapter",
|
||||
search_adapter: "DuckDuckGoSearchAdapter",
|
||||
storage_dir: "/var/lib/researchx/runs",
|
||||
probe: null,
|
||||
cost_counters: { runs_created: 0, runs_completed: 0 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
const config = await client.getConfigStatus();
|
||||
expect(config.resolved_tier).toBe("keyless");
|
||||
expect(config.key_store).toBe("disabled");
|
||||
expect(config.key_status.disabled).toBe(true);
|
||||
expect(config.key_status.reason).toContain("RESEARCHX_KEK");
|
||||
expect(config.cost_counters).toEqual({ runs_created: 0, runs_completed: 0 });
|
||||
});
|
||||
});
|
||||
+121
-3
@@ -382,6 +382,34 @@ export interface ConnectionSlot {
|
||||
status: "connected" | "fallback" | "preview";
|
||||
/** Human detail, e.g. "Ollama cloud — key detected" or "Preview until v0.2". */
|
||||
detail: string;
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* v0.3 P2 per-user credential fields (T-2.9, REQ-74). The resolved */
|
||||
/* tier applies to the "model" slot; the key fields mirror the */
|
||||
/* backend's GET /api/config. Mock mode fills honest fixture values */
|
||||
/* (keyless / key store disabled). Older consumers that only read */
|
||||
/* slot/label/status/detail stay source-compatible. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/** Credential tier this user's runs resolve to (model slot only). */
|
||||
resolvedTier?: "user" | "server" | "keyless";
|
||||
/** Whether the per-user key store is enabled server-side. */
|
||||
keyStore?: "enabled" | "disabled";
|
||||
/** Masked-only per-user model-key status — never the raw key. */
|
||||
keyStatus?: ModelKeyStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Masked-only model-key status (P2, REQ-73 — mirrors the backend's
|
||||
* KeyStatus). The raw key NEVER appears in any field.
|
||||
*/
|
||||
export interface ModelKeyStatus {
|
||||
/** True when a key is stored for the current user. */
|
||||
configured: boolean;
|
||||
/** Masked tail — "…7f2a" shape; null when nothing is stored. */
|
||||
masked: string | null;
|
||||
/** True when the key store is disabled server-side (no KEK). */
|
||||
disabled: boolean;
|
||||
/** Honest reason for a disabled/unreadable store — surfaced to the UI. */
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/** The whole settings payload (maps to GET/PUT /api/config in v0.2). */
|
||||
@@ -397,9 +425,71 @@ export interface Settings {
|
||||
theme: "paper";
|
||||
}
|
||||
|
||||
/** The local single-user profile behind the avatar button (REQ-66). */
|
||||
export interface UserProfile {
|
||||
/** Display name — local profile, no auth (single-user constraint). */
|
||||
/**
|
||||
* Per-user settings (P2, REQ-72 — mirrors the backend's UserSettings
|
||||
* from GET/PUT /api/config/settings). Model names + methodology
|
||||
* defaults + appearance, one payload per user; fresh users get the
|
||||
* server defaults (planner/writer "gpt-oss:20b", appearance "system").
|
||||
*/
|
||||
export interface UserSettings {
|
||||
/** Model name the planner role runs (e.g. "gpt-oss:20b"). */
|
||||
planner_model: string;
|
||||
/** Model name the writer role runs. */
|
||||
writer_model: string;
|
||||
/** Resolved dial overrides per methodology — free-form, validated at use. */
|
||||
methodology_defaults: Record<string, unknown>;
|
||||
/** Appearance slot — "system" (the light palette is the only render today). */
|
||||
appearance: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-user credential/usage status from GET /api/config (P2,
|
||||
* REQ-74) — the live-mode settings surface. Every field is
|
||||
* display-only except keyStatus, which drives the masked key entry.
|
||||
*/
|
||||
export interface ConfigStatus {
|
||||
/** Credential tier the CURRENT user's runs resolve to. */
|
||||
resolved_tier: "user" | "server" | "keyless";
|
||||
/** Whether the server's per-user key store is enabled. */
|
||||
key_store: "enabled" | "disabled";
|
||||
/** Masked-only key status — never the raw key. */
|
||||
key_status: ModelKeyStatus;
|
||||
/** Adapter class names (display-only). */
|
||||
model_adapter: string;
|
||||
search_adapter: string;
|
||||
/** Server-managed storage directory (display-only — D-111, no picker). */
|
||||
storage_dir: string;
|
||||
/** Capability-probe snapshot, when the adapter supports probing. */
|
||||
probe: Record<string, unknown> | null;
|
||||
/** Per-user usage counters (P-13). */
|
||||
cost_counters: {
|
||||
runs_created: number;
|
||||
runs_completed: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed key-store refusal (P2, REQ-73): PUT model-key answered 409
|
||||
* because the server's key store is disabled (no KEK). Carries the
|
||||
* honest reason so the settings UI can say WHY, never a silent no-op.
|
||||
*/
|
||||
export class KeyStoreDisabledError extends Error {
|
||||
readonly reason: string;
|
||||
|
||||
constructor(reason: string) {
|
||||
super(reason);
|
||||
this.name = "KeyStoreDisabledError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The menu/display profile behind the avatar button (REQ-66).
|
||||
* v0.2 prototype shape — name + initials + tagline for the local
|
||||
* single-user workspace. Kept for the getUserProfile() surface.
|
||||
*/
|
||||
export interface DisplayProfile {
|
||||
/** Display name. */
|
||||
name: string;
|
||||
/** Two-letter initials rendered inside the avatar disc. */
|
||||
initials: string;
|
||||
@@ -407,6 +497,34 @@ export interface UserProfile {
|
||||
tagline: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Auth session (P1 vocabulary — REQ-69, REQ-70) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The authenticated user as the wire exposes them (T-1.10, REQ-69).
|
||||
* Mirrors the backend's UserPublic: no password hash, ever.
|
||||
*/
|
||||
export interface UserProfile {
|
||||
/** 8-char backend slug, e.g. "dev00000". */
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
/** "admin" | "user". */
|
||||
role: string;
|
||||
active: boolean;
|
||||
/** ISO timestamp of account creation. */
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Input to the admin user-create hook (REQ-70). */
|
||||
export interface CreateUserInput {
|
||||
email: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
role?: "admin" | "user";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Ask intelligence (P7 vocabulary — REQ-67, REQ-68) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user