From a84d66be60580f81c75c82646ad64c6e82a497f6 Mon Sep 17 00:00:00 2001 From: Jayson Reis Date: Fri, 25 Sep 2026 18:11:02 +0200 Subject: [PATCH] feat(web): add a root-only /web/pending triage page (#855) Add a server-rendered page under /web that lists the pending auto-improvement proposals of all projects. The page has a project filter and a sort. It shows the rationale, the proposed body, and a warning for proposals that write under _rules/ or replace a page. The approve and reject buttons post from the browser to the existing /admin/pending-writes/{id}/approve|reject handlers with the session cookie and the CSRF header. Admission, audit, attribution, and the single writer stay the same. ai-memory-web adds no write route. The page uses the same Capability::Admin decision as /admin, and serve passes the trusted-proxy setting to it. A non-root session gets a 403 page. The HTML auth redirect does not send that session to the change-password form. Two store reads feed the page. One counts pending proposals per project, so the total and the project filter include every project. The other lists proposals, optionally for one project, with a cap of 500 rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SwU4vj4tZLYVLR7vEKeh2W --- CHANGELOG.md | 7 + crates/ai-memory-cli/src/commands/serve.rs | 4 + .../suite/admin_pending_writes_session.rs | 405 +++++++++++++ crates/ai-memory-mcp/tests/suite/mod.rs | 1 + crates/ai-memory-store/src/auto_improve.rs | 39 ++ crates/ai-memory-store/src/lib.rs | 6 +- crates/ai-memory-store/src/reader.rs | 112 +++- crates/ai-memory-web/src/html_auth.rs | 12 +- crates/ai-memory-web/src/lib.rs | 2 + crates/ai-memory-web/src/mount.rs | 29 +- crates/ai-memory-web/src/routes/mod.rs | 2 + crates/ai-memory-web/src/routes/pending.rs | 228 ++++++++ crates/ai-memory-web/src/state.rs | 17 +- crates/ai-memory-web/src/templates.rs | 77 ++- crates/ai-memory-web/static/tailwind.css | 2 +- .../templates/admin_required.html | 14 + crates/ai-memory-web/templates/base.html | 1 + crates/ai-memory-web/templates/pending.html | 179 ++++++ crates/ai-memory-web/tests/suite/mod.rs | 1 + crates/ai-memory-web/tests/suite/pending.rs | 549 ++++++++++++++++++ docs/ARCHITECTURE.md | 4 +- docs/auto-improvement-loop.md | 1 + docs/frontend-api.md | 6 +- docs/security-boundaries.md | 1 + 24 files changed, 1681 insertions(+), 18 deletions(-) create mode 100644 crates/ai-memory-mcp/tests/suite/admin_pending_writes_session.rs create mode 100644 crates/ai-memory-web/src/routes/pending.rs create mode 100644 crates/ai-memory-web/templates/admin_required.html create mode 100644 crates/ai-memory-web/templates/pending.html create mode 100644 crates/ai-memory-web/tests/suite/pending.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 044e8341..53fd0716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- The builtin web UI has a root-only `/web/pending` page to triage pending + auto-improvement proposals. It lists the proposals of all projects, with a + project filter and a sort, and shows the rationale and the proposed body. + Approve and reject post to the existing + `/admin/pending-writes/{id}/approve|reject` routes with the session cookie + and the CSRF header, so admission, audit, and attribution stay the same. + `ai-memory-web` adds no write route. (#855) - Inert per-project-authorization schema and the `authorize_project` choke point (first slice of #708). A new `project_grants` table (`(workspace, project, user) -> read|write`) and a `projects.access_mode` diff --git a/crates/ai-memory-cli/src/commands/serve.rs b/crates/ai-memory-cli/src/commands/serve.rs index 282b01e8..9e02f4f1 100644 --- a/crates/ai-memory-cli/src/commands/serve.rs +++ b/crates/ai-memory-cli/src/commands/serve.rs @@ -1481,6 +1481,7 @@ pub async fn run(config: &Config, args: ServeArgs) -> Result<()> { web_slug: &args.web_slug, base_href: &base_href, base_path: &base_path, + trusted_proxy_identity: trusted_proxy_identity_enabled(&config.auth), }, )?; // HTML navigational 401/403 → builtin login / change-password. @@ -4277,6 +4278,7 @@ mod tests { web_slug: "/web", base_href: "/web/", base_path: "", + trusted_proxy_identity: false, }, ) .unwrap(); @@ -4421,6 +4423,7 @@ mod tests { web_slug: "/web", base_href: "/web/", base_path: "", + trusted_proxy_identity: false, }, ) .unwrap(); @@ -4481,6 +4484,7 @@ mod tests { web_slug: "/", base_href: "/", base_path: "", + trusted_proxy_identity: false, }, ) .unwrap(); diff --git a/crates/ai-memory-mcp/tests/suite/admin_pending_writes_session.rs b/crates/ai-memory-mcp/tests/suite/admin_pending_writes_session.rs new file mode 100644 index 00000000..a680d0d8 --- /dev/null +++ b/crates/ai-memory-mcp/tests/suite/admin_pending_writes_session.rs @@ -0,0 +1,405 @@ +//! Adversarial tests for browser-session decisions on pending writes. +//! +//! The builtin `/web/pending` triage page posts approve and reject from the +//! browser, with the password session cookie and the CSRF header, to the +//! existing `/admin/pending-writes/{id}/approve|reject` handlers. These tests +//! drive that exact path through the production `require_dual_auth` and the +//! root gate on the admin router: every refused attempt must leave the +//! proposal pending and the target file absent. + +use std::sync::Arc; + +use ai_memory_core::{ + ActorContext, AutoImproveProposalId, NewUser, PagePath, ProjectId, UserId, UserRole, + WorkspaceId, +}; +use ai_memory_mcp::auth::AuthState; +use ai_memory_mcp::human_auth::{CSRF_COOKIE, CSRF_HEADER, LoginLimiter, SESSION_COOKIE}; +use ai_memory_mcp::{ + AdminState, HumanAuthRuntime, admin_router, public_auth_router, require_dual_auth, +}; +use ai_memory_store::{ + AutoImproveProposalOperation, AutoImproveProposalStatus, DecayParams, NewAutoImproveProposal, + StageAutoImproveRun, Store, +}; +use ai_memory_wiki::Wiki; +use axum::Router; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use tempfile::TempDir; +use tower::ServiceExt; + +const ROOT_PASSWORD: &str = "root-password-12"; +const USER_PASSWORD: &str = "alice-password-12"; +const TARGET: &str = "notes/pending.md"; + +struct Session { + session: String, + csrf: String, +} + +struct Harness { + _tmp: TempDir, + store: Store, + wiki: Wiki, + router: Router, + root_id: UserId, + root: Session, + user: Session, + ws: WorkspaceId, + proj: ProjectId, + proposal: AutoImproveProposalId, +} + +async fn harness() -> Harness { + let tmp = TempDir::new().unwrap(); + let store = Store::open(tmp.path()).unwrap(); + let wiki = Wiki::new(tmp.path(), store.writer.clone()) + .unwrap() + .with_store_reader(store.reader.clone()); + + let root_id = create_user(&store, "root", UserRole::Root, ROOT_PASSWORD).await; + create_user(&store, "alice", UserRole::User, USER_PASSWORD).await; + let (ws, proj, proposal) = stage(&store).await; + + let auth = Arc::new( + AuthState::new(Some("root-bearer".into())) + .with_root_actor(ActorContext { + user: Some("root".into()), + ..ActorContext::default() + }) + .with_human(HumanAuthRuntime { + reader: store.reader.clone(), + writer: store.writer.clone(), + recovery_token_hash: None, + root_username: "root".into(), + root_name: None, + root_email: None, + reserved_passwords: vec!["root-bearer".into()], + trusted_proxy_cidrs: Vec::new(), + limiter: Arc::new(LoginLimiter::default()), + }), + ); + let root = login(auth.clone(), "root", ROOT_PASSWORD).await; + let user = login(auth.clone(), "alice", USER_PASSWORD).await; + let router = admin_router(admin_state(&tmp, &store, wiki.clone())).layer( + axum::middleware::from_fn_with_state(auth, require_dual_auth), + ); + + Harness { + _tmp: tmp, + store, + wiki, + router, + root_id, + root, + user, + ws, + proj, + proposal, + } +} + +async fn create_user(store: &Store, username: &str, role: UserRole, password: &str) -> UserId { + let phc = ai_memory_store::password::hash_password(password.to_owned()) + .await + .unwrap(); + let new_user = NewUser { + username: username.into(), + name: None, + email: None, + }; + store + .writer + .create_human_user(new_user, role, Some(phc), false) + .await + .unwrap() +} + +async fn stage(store: &Store) -> (WorkspaceId, ProjectId, AutoImproveProposalId) { + let ws = store + .writer + .get_or_create_workspace("default") + .await + .unwrap(); + let proj = store + .writer + .get_or_create_project(ws, "scratch", None) + .await + .unwrap(); + let staged = store + .writer + .stage_auto_improve_run(StageAutoImproveRun { + workspace_id: ws, + project_id: proj, + session_id: None, + provider: Some("test".into()), + model: Some("model".into()), + summary: Some("summary".into()), + warnings_json: serde_json::json!([]), + rejected_candidates_json: serde_json::json!([]), + config_json: serde_json::json!({"mode": "stage"}), + proposal_actor: ActorContext { + agent: Some("auto_improve".into()), + ..ActorContext::default() + }, + proposals: vec![NewAutoImproveProposal { + operation: AutoImproveProposalOperation::Create, + target_path: PagePath::new(TARGET).unwrap(), + kind: "note".into(), + title: "Pending title".into(), + confidence: 0.9, + rationale: "rationale".into(), + evidence_json: serde_json::json!([{"source": "test"}]), + body_markdown: "# Pending\n\nproposed body".into(), + artifact_sha256: None, + edit_mode: None, + patch_json: None, + expected_base_body_sha256: None, + }], + }) + .await + .unwrap(); + (ws, proj, staged.proposal_ids[0]) +} + +fn admin_state(tmp: &TempDir, store: &Store, wiki: Wiki) -> AdminState { + AdminState { + ingest_metrics: Arc::new(ai_memory_core::IngestMetrics::default()), + writer: store.writer.clone(), + reader: store.reader.clone(), + wiki, + llm: None, + auto_improve_require_approval: true, + auto_improve_review_config: Default::default(), + embedder: None, + provider_health: ai_memory_llm::ProviderHealth::default(), + decay_params: DecayParams::default(), + contradiction_band_min: ai_memory_consolidate::DEFAULT_CONTRADICTION_SIM_LOW, + contradiction_band_max: ai_memory_consolidate::DEFAULT_CONTRADICTION_SIM_HIGH, + data_dir: tmp.path().to_path_buf(), + db_path: store.db_path().to_path_buf(), + bind: "127.0.0.1:0".to_string(), + home_dir: None, + bootstrap_lock: Arc::new(tokio::sync::Mutex::new(())), + token_pepper: None, + active_project: ai_memory_core::ActiveProject::new(), + scope_invalidator: None, + trusted_proxy_identity: false, + } +} + +fn set_cookie(headers: &axum::http::HeaderMap, name: &str) -> String { + headers + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .find_map(|v| v.strip_prefix(&format!("{name}="))) + .and_then(|rest| rest.split(';').next()) + .unwrap_or_else(|| panic!("{name} cookie missing")) + .to_owned() +} + +async fn login(auth: Arc, username: &str, password: &str) -> Session { + let resp = public_auth_router(auth) + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&serde_json::json!({ + "username": username, + "password": password, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "login {username}"); + Session { + session: set_cookie(resp.headers(), SESSION_COOKIE), + csrf: set_cookie(resp.headers(), CSRF_COOKIE), + } +} + +/// What the browser sends for a decision. +enum Csrf<'a> { + None, + Header(&'a str), +} + +impl Harness { + async fn decide(&self, action: &str, session: Option<&Session>, csrf: Csrf<'_>) -> StatusCode { + let body = if action == "reject" { + serde_json::json!({"reason": "not useful"}) + } else { + serde_json::json!({}) + }; + let mut req = Request::builder() + .method("POST") + .uri(format!( + "/admin/pending-writes/{}/{action}?workspace=default&project=scratch", + self.proposal + )) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(s) = session { + req = req.header( + header::COOKIE, + format!("{SESSION_COOKIE}={}; {CSRF_COOKIE}={}", s.session, s.csrf), + ); + } + if let Csrf::Header(value) = csrf { + req = req.header(CSRF_HEADER, value); + } + self.router + .clone() + .oneshot( + req.body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap() + .status() + } + + async fn detail(&self) -> ai_memory_store::AutoImproveProposalDetail { + self.store + .reader + .auto_improve_proposal_detail(self.ws, self.proj, self.proposal) + .await + .unwrap() + .unwrap() + } + + fn target_file(&self) -> std::path::PathBuf { + self.wiki + .abs_path(self.ws, self.proj, &PagePath::new(TARGET).unwrap()) + } + + async fn assert_untouched(&self, context: &str) { + assert_eq!( + self.detail().await.summary.status, + AutoImproveProposalStatus::Pending, + "{context}: proposal left the queue" + ); + assert!( + !self.target_file().exists(), + "{context}: target file was written" + ); + assert!( + self.store + .reader + .page_body_by_ids(self.ws, self.proj, TARGET) + .await + .unwrap() + .is_none(), + "{context}: target page was indexed" + ); + } +} + +#[tokio::test] +async fn anonymous_browser_cannot_decide_a_pending_write() { + let h = harness().await; + for action in ["approve", "reject"] { + let status = h.decide(action, None, Csrf::None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "anonymous {action}"); + h.assert_untouched(&format!("anonymous {action}")).await; + } +} + +#[tokio::test] +async fn non_root_session_cannot_decide_a_pending_write() { + let h = harness().await; + for action in ["approve", "reject"] { + let status = h + .decide(action, Some(&h.user), Csrf::Header(&h.user.csrf)) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "db user {action}"); + h.assert_untouched(&format!("db user {action}")).await; + } +} + +#[tokio::test] +async fn root_session_without_a_matching_csrf_header_is_refused() { + let h = harness().await; + for action in ["approve", "reject"] { + let missing = h.decide(action, Some(&h.root), Csrf::None).await; + assert_eq!(missing, StatusCode::FORBIDDEN, "missing csrf {action}"); + h.assert_untouched(&format!("missing csrf {action}")).await; + + // Another session's token does not stand in for this one's. + let foreign = h + .decide(action, Some(&h.root), Csrf::Header(&h.user.csrf)) + .await; + assert_eq!(foreign, StatusCode::FORBIDDEN, "foreign csrf {action}"); + h.assert_untouched(&format!("foreign csrf {action}")).await; + } +} + +#[tokio::test] +async fn root_session_approval_keeps_auto_improve_provenance_and_the_approver() { + let h = harness().await; + let status = h + .decide("approve", Some(&h.root), Csrf::Header(&h.root.csrf)) + .await; + assert_eq!(status, StatusCode::OK); + + let detail = h.detail().await; + assert_eq!(detail.summary.status, AutoImproveProposalStatus::Approved); + assert_eq!(detail.decided_by_author_id, Some(h.root_id)); + assert_eq!( + detail + .decided_by_actor_json + .as_ref() + .and_then(|a| a["user"].as_str()), + Some("root") + ); + // The stage event still names the automated proposer. + let staged = detail + .events + .iter() + .find(|e| e.event == "staged") + .expect("staged event"); + assert_eq!(staged.actor_json["agent"], "auto_improve"); + + let file = std::fs::read_to_string(h.target_file()).expect("approval writes the page"); + assert!(file.contains("proposed body"), "{file}"); + assert!( + file.contains(&format!("auto_improve_proposal_id: {}", h.proposal)), + "{file}" + ); + assert!( + file.contains(&format!("auto_improve_run_id: {}", detail.summary.run_id)), + "{file}" + ); + assert!(file.contains("last_modified_by"), "{file}"); + assert!(file.contains("username: root"), "{file}"); +} + +#[tokio::test] +async fn root_session_rejection_records_the_decision_and_writes_no_file() { + let h = harness().await; + let status = h + .decide("reject", Some(&h.root), Csrf::Header(&h.root.csrf)) + .await; + assert_eq!(status, StatusCode::OK); + + let detail = h.detail().await; + assert_eq!(detail.summary.status, AutoImproveProposalStatus::Rejected); + assert_eq!(detail.decision_reason.as_deref(), Some("not useful")); + assert_eq!(detail.decided_by_author_id, Some(h.root_id)); + assert!(!h.target_file().exists(), "reject wrote the target file"); + assert!( + h.store + .reader + .page_body_by_ids(h.ws, h.proj, TARGET) + .await + .unwrap() + .is_none(), + "reject indexed the target page" + ); +} diff --git a/crates/ai-memory-mcp/tests/suite/mod.rs b/crates/ai-memory-mcp/tests/suite/mod.rs index 1ef69bf8..434bcfdb 100644 --- a/crates/ai-memory-mcp/tests/suite/mod.rs +++ b/crates/ai-memory-mcp/tests/suite/mod.rs @@ -10,6 +10,7 @@ mod admin_backup; mod admin_bootstrap; mod admin_move; mod admin_move_session; +mod admin_pending_writes_session; mod admin_phase3; mod admin_provider_error_logging; mod admin_purge; diff --git a/crates/ai-memory-store/src/auto_improve.rs b/crates/ai-memory-store/src/auto_improve.rs index 8ee06952..202bc343 100644 --- a/crates/ai-memory-store/src/auto_improve.rs +++ b/crates/ai-memory-store/src/auto_improve.rs @@ -313,6 +313,45 @@ pub struct OwnedAutoImproveProposalDetail { pub staged_by_actor_user: Option, } +/// One pending proposal with the names and bodies a human reviewer reads. +/// +/// Feeds the root-only `/web/pending` triage page, which lists every project's +/// queue at once; the decision itself still goes through +/// `/admin/pending-writes/{id}/approve|reject`. +#[derive(Debug, Clone, Serialize)] +pub struct PendingAutoImproveReview { + /// The list-view fields. + pub summary: AutoImproveProposalSummary, + /// Owning workspace name. + pub workspace_name: String, + /// Owning project name. + pub project_name: String, + /// Why the reviewer proposed this edit. + pub rationale: String, + /// Full proposed page body. + pub body_markdown: String, + /// `full_page` or `patch`. + pub edit_mode: String, +} + +/// One project that has pending proposals, with its pending count. +/// +/// Feeds the `/web/pending` project filter, so every project with a pending +/// proposal is listed even when the page reads only part of the queue. +#[derive(Debug, Clone, Serialize)] +pub struct PendingAutoImproveScope { + /// Owning workspace. + pub workspace_id: WorkspaceId, + /// Owning project. + pub project_id: ProjectId, + /// Workspace name. + pub workspace_name: String, + /// Project name. + pub project_name: String, + /// Pending proposals in this project. + pub pending: u64, +} + /// One append-only status-history entry for a proposal. #[derive(Debug, Clone, Serialize)] pub struct AutoImproveProposalEvent { diff --git a/crates/ai-memory-store/src/lib.rs b/crates/ai-memory-store/src/lib.rs index f3e86ace..5b08c775 100644 --- a/crates/ai-memory-store/src/lib.rs +++ b/crates/ai-memory-store/src/lib.rs @@ -42,9 +42,9 @@ pub use auto_improve::{ AutoImproveProposalDetail, AutoImproveProposalEvent, AutoImproveProposalOperation, AutoImproveProposalStatus, AutoImproveProposalSummary, AutoImproveRejectionSummary, AutoImproveTelemetryAggregate, AutoImproveTelemetryCount, FailAutoImproveProposal, - NewAutoImproveProposal, OwnedAutoImproveProposalDetail, RejectAutoImproveProposal, - SkippedProposal, StageAutoImproveRun, StagedAutoImproveRun, StagedAutoImproveRunReport, - artifact_path_for, + NewAutoImproveProposal, OwnedAutoImproveProposalDetail, PendingAutoImproveReview, + PendingAutoImproveScope, RejectAutoImproveProposal, SkippedProposal, StageAutoImproveRun, + StagedAutoImproveRun, StagedAutoImproveRunReport, artifact_path_for, }; pub use belief::{BeliefInputs, CONFIDENCE_CAP, confidence}; pub use decay::{ diff --git a/crates/ai-memory-store/src/reader.rs b/crates/ai-memory-store/src/reader.rs index c70017df..7345c38f 100644 --- a/crates/ai-memory-store/src/reader.rs +++ b/crates/ai-memory-store/src/reader.rs @@ -29,8 +29,8 @@ use uuid::Uuid; use crate::auto_improve::{ AutoImproveProposalDetail, AutoImproveProposalEvent, AutoImproveProposalStatus, AutoImproveProposalSummary, AutoImproveRejectionSummary, AutoImproveTelemetryAggregate, - AutoImproveTelemetryCount, OwnedAutoImproveProposalDetail, bytes32, opt_bytes32, - summary_from_row, to_sql_err, + AutoImproveTelemetryCount, OwnedAutoImproveProposalDetail, PendingAutoImproveReview, + PendingAutoImproveScope, bytes32, opt_bytes32, summary_from_row, to_sql_err, }; use crate::error::{StoreError, StoreResult}; use crate::fts_query::prepare_fts5_query; @@ -7862,6 +7862,114 @@ impl ReaderPool { .await } + /// Every project with pending auto-improvement proposals, with its + /// pending count, ordered by workspace and project name. + /// + /// Unscoped by design: the only caller is the root-only `/web/pending` + /// triage page, which gates on `Capability::Admin` before reading. + /// + /// # Errors + /// Propagates any SQL or pool error. + pub async fn list_pending_auto_improve_scopes( + &self, + ) -> StoreResult> { + self.with_conn(move |conn| { + let mut stmt = conn.prepare( + "SELECT p.workspace_id, p.project_id, workspaces.name, projects.name, \ + COUNT(*) \ + FROM auto_improve_proposals p \ + JOIN workspaces ON workspaces.id = p.workspace_id \ + JOIN projects ON projects.id = p.project_id \ + AND projects.workspace_id = p.workspace_id \ + WHERE p.status = ?1 \ + GROUP BY p.workspace_id, p.project_id \ + ORDER BY workspaces.name, projects.name", + )?; + let rows = stmt.query_map( + params![AutoImproveProposalStatus::Pending.as_str()], + |row| { + let pending: i64 = row.get(4)?; + Ok(PendingAutoImproveScope { + workspace_id: WorkspaceId::from_slice(&row.get::<_, Vec>(0)?) + .map_err(to_sql_err)?, + project_id: ProjectId::from_slice(&row.get::<_, Vec>(1)?) + .map_err(to_sql_err)?, + workspace_name: row.get(2)?, + project_name: row.get(3)?, + pending: u64::try_from(pending).unwrap_or_default(), + }) + }, + )?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) + }) + .await + } + + /// List pending auto-improvement proposals, oldest first, with the names + /// and bodies a reviewer needs. `scope` limits the list to one project; + /// `None` lists every project. + /// + /// Unscoped by design when `scope` is `None`: the only caller is the + /// root-only `/web/pending` triage page, which gates on + /// `Capability::Admin` before reading. One joined query instead of a + /// per-project fan-out. + /// + /// # Errors + /// Propagates any SQL or pool error. + pub async fn list_pending_auto_improve_reviews( + &self, + scope: Option<(WorkspaceId, ProjectId)>, + limit: usize, + ) -> StoreResult> { + self.with_conn(move |conn| { + let limit = i64::try_from(limit).unwrap_or(i64::MAX); + let (workspace_id, project_id) = scope + .map(|(ws, proj)| (Some(ws.as_bytes().to_vec()), Some(proj.as_bytes().to_vec()))) + .unwrap_or_default(); + let mut stmt = conn.prepare( + "SELECT p.id, p.run_id, p.workspace_id, p.project_id, p.status, p.operation, \ + p.target_path, p.kind, p.title, p.confidence, p.staged_at, \ + p.decided_at, workspaces.name, projects.name, p.rationale, \ + p.body_markdown, p.edit_mode \ + FROM auto_improve_proposals p \ + JOIN workspaces ON workspaces.id = p.workspace_id \ + JOIN projects ON projects.id = p.project_id \ + AND projects.workspace_id = p.workspace_id \ + WHERE p.status = ?1 \ + AND (?2 IS NULL OR (p.workspace_id = ?2 AND p.project_id = ?3)) \ + ORDER BY p.staged_at ASC LIMIT ?4", + )?; + let rows = stmt.query_map( + params![ + AutoImproveProposalStatus::Pending.as_str(), + workspace_id, + project_id, + limit + ], + |row| { + Ok(PendingAutoImproveReview { + summary: summary_from_row(row)?, + workspace_name: row.get(12)?, + project_name: row.get(13)?, + rationale: row.get(14)?, + body_markdown: row.get(15)?, + edit_mode: row.get(16)?, + }) + }, + )?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) + }) + .await + } + /// Read one proposal by id, failing closed when the scope does not match. pub async fn auto_improve_proposal_detail( &self, diff --git a/crates/ai-memory-web/src/html_auth.rs b/crates/ai-memory-web/src/html_auth.rs index 86f3e7e9..004e20fb 100644 --- a/crates/ai-memory-web/src/html_auth.rs +++ b/crates/ai-memory-web/src/html_auth.rs @@ -61,6 +61,14 @@ fn join_root(web_root: &str, leaf: &str) -> String { } } +/// Response marker for a 403 that means "this page is root-only". +/// +/// [`html_auth_redirect_mw`] sends every other HTML 403 to the +/// change-password form; a signed-in non-root user must see the refusal +/// instead. +#[derive(Clone, Copy, Debug)] +pub(crate) struct AdminRequired; + /// Allow only same-origin relative paths under `web_root`. /// /// Rejects protocol-relative `//`, absolute URLs (`http:` / `https:`), @@ -141,7 +149,9 @@ pub async fn html_auth_redirect_mw( urlencoding_encode(&sanitize_next(Some(&full_path_and_query), &cfg.web_root)); Redirect::to(&format!("{}?next={next_q}", cfg.login_path)).into_response() } - StatusCode::FORBIDDEN => Redirect::to(&cfg.change_password_path).into_response(), + StatusCode::FORBIDDEN if resp.extensions().get::().is_none() => { + Redirect::to(&cfg.change_password_path).into_response() + } _ => resp, } } diff --git a/crates/ai-memory-web/src/lib.rs b/crates/ai-memory-web/src/lib.rs index 6e41e207..439bf1f3 100644 --- a/crates/ai-memory-web/src/lib.rs +++ b/crates/ai-memory-web/src/lib.rs @@ -12,6 +12,8 @@ //! - `GET /w/:workspace/:project` → page tree + recent activity //! - `GET /w/:workspace/:project/p/*path` → rendered markdown + metadata //! - `GET /search?q=…` → FTS5 hit list +//! - `GET /pending` → root-only pending-writes triage; +//! its buttons post to `/admin/pending-writes/*`, not to this crate //! - `GET /login` / `GET /change-password` → public human-auth HTML forms //! - `GET /static/*` → embedded CSS + logo (public) //! diff --git a/crates/ai-memory-web/src/mount.rs b/crates/ai-memory-web/src/mount.rs index cbc099ab..bd26d75d 100644 --- a/crates/ai-memory-web/src/mount.rs +++ b/crates/ai-memory-web/src/mount.rs @@ -291,6 +291,9 @@ pub struct WebMountSpec<'a> { /// Normalised base path (`""` or `/`) the whole surface is /// nested under; stamped into the `ai-memory-base-path` meta tag. pub base_path: &'a str, + /// A trusted identity proxy is configured; the root-only pending-writes + /// page needs it to decide whether operators are told apart. + pub trusted_proxy_identity: bool, } /// Public SPA vs dual-auth wiki/API split. @@ -360,7 +363,14 @@ pub fn split_web_routers( spec.base_path, mount, ), - protected: mount_builtin_browser(protected_api, reader, wiki, &slug, mount, browser_inject), + protected: mount_builtin_browser( + protected_api, + crate::WebState::new(reader, wiki) + .with_trusted_proxy_identity(spec.trusted_proxy_identity), + &slug, + mount, + browser_inject, + ), html_auth: Some(auth_cfg), }) } @@ -481,8 +491,7 @@ fn mount_builtin_public( /// query string the caller passed. fn mount_builtin_browser( router: axum::Router, - reader: ReaderPool, - wiki: Wiki, + state: crate::WebState, slug: &str, mount: &str, inject: Arc, @@ -492,10 +501,9 @@ fn mount_builtin_browser( // response so they resolve under `{base_path}{web_slug}/` — the // same anchoring the custom SPA gets via its injected index. let base_href = inject.base_href.clone(); - let web_router = crate::router(reader, wiki).layer(axum::middleware::from_fn_with_state( - inject, - inject_web_base_href, - )); + let web_router = crate::routes::build(Arc::new(state)).layer( + axum::middleware::from_fn_with_state(inject, inject_web_base_href), + ); info!(mount, base_href, "read-only wiki browser mounted"); if slug.is_empty() { return router.merge(web_router); @@ -653,6 +661,7 @@ mod tests { web_slug, base_href: &base_href, base_path: &base, + trusted_proxy_identity: false, }, ) .unwrap(); @@ -1053,6 +1062,7 @@ mod tests { web_slug: "/web", base_href: &base_href, base_path: &base, + trusted_proxy_identity: false, }, ) .unwrap(); @@ -1132,6 +1142,7 @@ mod tests { web_slug: "/web", base_href: &base_href, base_path: "", + trusted_proxy_identity: false, }, ) .unwrap(); @@ -1190,6 +1201,7 @@ mod tests { web_slug: "/", base_href: &base_href, base_path: &base, + trusted_proxy_identity: false, }, ) .unwrap(); @@ -1277,6 +1289,7 @@ mod tests { web_slug: "/web", base_href: "/web/", base_path: "", + trusted_proxy_identity: false, }, ) .unwrap(); @@ -1332,6 +1345,7 @@ mod tests { web_slug: "/web", base_href: "/web/", base_path: "", + trusted_proxy_identity: false, }, ) .unwrap(); @@ -1376,6 +1390,7 @@ mod tests { web_slug: "/web", base_href: "/web/", base_path: "", + trusted_proxy_identity: false, }, ) .unwrap(); diff --git a/crates/ai-memory-web/src/routes/mod.rs b/crates/ai-memory-web/src/routes/mod.rs index 4eca0872..7567467e 100644 --- a/crates/ai-memory-web/src/routes/mod.rs +++ b/crates/ai-memory-web/src/routes/mod.rs @@ -12,6 +12,7 @@ mod api; mod index; mod login; mod page; +mod pending; mod project; mod search; mod statics; @@ -23,6 +24,7 @@ pub(crate) fn build(state: Arc) -> Router { .route("/w/{workspace}/{project}", get(project::handler)) .route("/w/{workspace}/{project}/p/{*path}", get(page::handler)) .route("/search", get(search::handler)) + .route("/pending", get(pending::handler)) .with_state(state) } diff --git a/crates/ai-memory-web/src/routes/pending.rs b/crates/ai-memory-web/src/routes/pending.rs new file mode 100644 index 00000000..2d907720 --- /dev/null +++ b/crates/ai-memory-web/src/routes/pending.rs @@ -0,0 +1,228 @@ +//! `GET /pending` — root-only triage page for pending auto-improvement +//! proposals across every project. +//! +//! The page only reads. Approve and reject buttons post from the browser to +//! the existing `/admin/pending-writes/{id}/approve|reject` handlers, which +//! keep admission, audit, and the single writer. This crate adds no write +//! path. + +use std::sync::Arc; + +use ai_memory_core::{AuthLevel, Capability}; +use ai_memory_store::{PendingAutoImproveReview, PendingAutoImproveScope}; +use askama::Template; +use axum::Extension; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Response}; +use serde::Deserialize; + +use crate::html_auth::AdminRequired; +use crate::state::WebState; +use crate::templates::{ + AdminRequiredView, PendingRow, PendingView, SelectOption, encode_segment, humanize, page_href, +}; + +/// Most proposals one page load reads. A queue this long needs the CLI, and +/// the cap keeps the page (which embeds every body) bounded. +const PENDING_REVIEW_LIMIT: usize = 500; + +const RULES_PREFIX: &str = "_rules/"; + +/// Sort orders the page offers, as `(value, label)`. The first is the default. +const SORTS: [(&str, &str); 4] = [ + (SORT_PROJECT, "Project, then staged date"), + (SORT_CONFIDENCE_ASC, "Confidence, low first"), + (SORT_CONFIDENCE_DESC, "Confidence, high first"), + (SORT_STAGED_DESC, "Staged, newest first"), +]; +const SORT_PROJECT: &str = "project"; +const SORT_CONFIDENCE_ASC: &str = "confidence-asc"; +const SORT_CONFIDENCE_DESC: &str = "confidence-desc"; +const SORT_STAGED_DESC: &str = "staged-desc"; + +#[derive(Debug, Default, Deserialize)] +pub(crate) struct PendingQuery { + /// Percent-encoded `workspace/project` key; empty means every project. + #[serde(default)] + project: Option, + #[serde(default)] + sort: Option, +} + +/// Handler for `GET /pending`. +pub(crate) async fn handler( + State(state): State>, + level: Option>, + Query(query): Query, +) -> Response { + if let Err(response) = require_admin(&state, level).await { + return response; + } + + let scopes = match state.reader.list_pending_auto_improve_scopes().await { + Ok(scopes) => scopes, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + // Match the request against known scopes only, so the filter can never + // name a project that has no pending proposal. + let filter = query + .project + .as_deref() + .and_then(|key| scopes.iter().find(|scope| scope_key(scope) == key)); + let sort = query + .sort + .as_deref() + .and_then(|raw| SORTS.iter().find(|(value, _)| *value == raw)) + .map_or(SORT_PROJECT, |(value, _)| *value); + + let mut reviews = match state + .reader + .list_pending_auto_improve_reviews( + filter.map(|scope| (scope.workspace_id, scope.project_id)), + PENDING_REVIEW_LIMIT + 1, + ) + .await + { + Ok(reviews) => reviews, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + let truncated = reviews.len() > PENDING_REVIEW_LIMIT; + reviews.truncate(PENDING_REVIEW_LIMIT); + sort_reviews(&mut reviews, sort); + + let total: u64 = scopes.iter().map(|scope| scope.pending).sum(); + let mut projects = vec![SelectOption { + value: String::new(), + label: format!("All projects ({total})"), + selected: filter.is_none(), + }]; + projects.extend(scopes.iter().map(|scope| SelectOption { + value: scope_key(scope), + label: format!( + "{} ({})", + display_scope(&scope.workspace_name, &scope.project_name), + scope.pending + ), + selected: filter.is_some_and(|f| std::ptr::eq(f, scope)), + })); + let sorts = SORTS + .iter() + .map(|(value, label)| SelectOption { + value: (*value).to_owned(), + label: (*label).to_owned(), + selected: *value == sort, + }) + .collect(); + + render( + PendingView { + rows: reviews.into_iter().map(pending_row).collect(), + projects, + sorts, + total, + project_count: scopes.len(), + truncated, + limit: PENDING_REVIEW_LIMIT, + }, + StatusCode::OK, + ) +} + +/// Filter value for one project. Each name is percent-encoded, so a `/` +/// inside a name cannot make two projects share one key. +fn scope_key(scope: &PendingAutoImproveScope) -> String { + format!( + "{}/{}", + encode_segment(&scope.workspace_name), + encode_segment(&scope.project_name) + ) +} + +/// Refuse the page unless the caller may use `/admin`, with the same +/// `Capability::Admin` decision `require_root_for_multiuser_admin` makes. +async fn require_admin( + state: &WebState, + level: Option>, +) -> Result<(), Response> { + let level = level.map_or(AuthLevel::Anonymous, |Extension(level)| level); + let distinguishes_operators = state + .reader + .distinguishes_operators(state.trusted_proxy_identity) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?; + match level.authorize(Capability::Admin, distinguishes_operators) { + Ok(()) => Ok(()), + Err(e) if e.is_authentication_required() => Err(StatusCode::UNAUTHORIZED.into_response()), + Err(_) => { + let mut response = render(AdminRequiredView {}, StatusCode::FORBIDDEN); + // A 403 here means "not root", not "change your password": keep + // the HTML auth redirect from sending the user to that form. + response.extensions_mut().insert(AdminRequired); + Err(response) + } + } +} + +fn scope_label(review: &PendingAutoImproveReview) -> String { + display_scope(&review.workspace_name, &review.project_name) +} + +/// `workspace/project` for display. When a name holds a `/`, both names are +/// quoted, so `a/b` + `c` and `a` + `b/c` stay distinct on screen. +fn display_scope(workspace: &str, project: &str) -> String { + if workspace.contains('/') || project.contains('/') { + format!("{workspace:?}/{project:?}") + } else { + format!("{workspace}/{project}") + } +} + +fn sort_reviews(reviews: &mut [PendingAutoImproveReview], sort: &str) { + reviews.sort_by(|a, b| { + let by_scope = || scope_label(a).cmp(&scope_label(b)); + let by_staged = a.summary.staged_at.cmp(&b.summary.staged_at); + let by_confidence = a.summary.confidence.total_cmp(&b.summary.confidence); + match sort { + SORT_CONFIDENCE_ASC => by_confidence.then_with(by_scope).then(by_staged), + SORT_CONFIDENCE_DESC => by_confidence.reverse().then_with(by_scope).then(by_staged), + SORT_STAGED_DESC => by_staged.reverse().then_with(by_scope), + _ => by_scope().then(by_staged), + } + }); +} + +fn pending_row(review: PendingAutoImproveReview) -> PendingRow { + let label = scope_label(&review); + let summary = review.summary; + let target_path = summary.target_path.as_str().to_owned(); + let staged_relative = jiff::Timestamp::from_microsecond(summary.staged_at) + .map(|ts| humanize(&ts.to_string())) + .unwrap_or_default(); + PendingRow { + id: summary.id.to_string(), + scope_label: label, + target_href: page_href(&review.workspace_name, &review.project_name, &target_path), + workspace: review.workspace_name, + project: review.project_name, + kind: summary.kind, + operation: summary.operation.as_str().to_owned(), + is_rule: target_path.starts_with(RULES_PREFIX), + rewrites_existing: summary.operation + != ai_memory_store::AutoImproveProposalOperation::Create, + target_path, + title: summary.title, + confidence_pct: (summary.confidence * 100.0).round() as i64, + staged_relative, + edit_mode: review.edit_mode, + rationale: review.rationale, + body_markdown: review.body_markdown, + } +} + +fn render(view: impl Template, status: StatusCode) -> Response { + match view.render() { + Ok(body) => (status, Html(body)).into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/crates/ai-memory-web/src/state.rs b/crates/ai-memory-web/src/state.rs index 7ef11c39..176a297d 100644 --- a/crates/ai-memory-web/src/state.rs +++ b/crates/ai-memory-web/src/state.rs @@ -16,12 +16,27 @@ pub struct WebState { pub reader: ReaderPool, /// Wiki handle — reads page bodies from disk. pub wiki: Wiki, + /// A trusted identity proxy asserts usernames, so the deployment tells + /// operators apart even with no `users` rows. Root-only pages pass it to + /// `ReaderPool::distinguishes_operators`, as the `/admin` gate does. + pub trusted_proxy_identity: bool, } impl WebState { /// Build a new shared state. #[must_use] pub fn new(reader: ReaderPool, wiki: Wiki) -> Self { - Self { reader, wiki } + Self { + reader, + wiki, + trusted_proxy_identity: false, + } + } + + /// Record whether a trusted identity proxy is configured. + #[must_use] + pub fn with_trusted_proxy_identity(mut self, trusted_proxy_identity: bool) -> Self { + self.trusted_proxy_identity = trusted_proxy_identity; + self } } diff --git a/crates/ai-memory-web/src/templates.rs b/crates/ai-memory-web/src/templates.rs index 9f4ea504..a985009e 100644 --- a/crates/ai-memory-web/src/templates.rs +++ b/crates/ai-memory-web/src/templates.rs @@ -39,7 +39,7 @@ fn encode_path(path: &str) -> String { .join("/") } -fn encode_segment(segment: &str) -> String { +pub(crate) fn encode_segment(segment: &str) -> String { let mut out = String::with_capacity(segment.len()); for byte in segment.bytes() { match byte { @@ -279,6 +279,81 @@ pub(crate) struct SearchView { pub hit_count: usize, } +// --------------------------------------------------------------------------- +// pending.html / admin_required.html +// --------------------------------------------------------------------------- + +/// One pending auto-improvement proposal on the triage page. +pub(crate) struct PendingRow { + /// Proposal id; the page posts it to `/admin/pending-writes/{id}/…`. + pub id: String, + /// Workspace name, sent as the `workspace` query parameter. + pub workspace: String, + /// Project name, sent as the `project` query parameter. + pub project: String, + /// `workspace/project`, the filter value and display label. + pub scope_label: String, + /// Proposal category. + pub kind: String, + /// `create` or `update`. + pub operation: String, + /// Target wiki path. + pub target_path: String, + /// Link to the current page, for proposals that rewrite one. + pub target_href: String, + /// Proposal title. + pub title: String, + /// Reviewer confidence as a whole percentage. + pub confidence_pct: i64, + /// Humanised stage time. + pub staged_relative: String, + /// `full_page` or `patch`. + pub edit_mode: String, + /// Why the reviewer proposed this edit. + pub rationale: String, + /// Full proposed page body, shown as plain text. + pub body_markdown: String, + /// The target is under `_rules/`, which every agent session loads. + pub is_rule: bool, + /// The proposal rewrites an existing page. + pub rewrites_existing: bool, +} + +/// One `