From 5936cf0b2ec1a727844e5810cd3debf2113b8dc3 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Fri, 10 Jul 2026 22:15:35 +0100 Subject: [PATCH] multiuser part 2 --- src/frontend/api/agents.rs | 2 +- src/frontend/api/approval.rs | 18 +++++---- src/frontend/api/inbox.rs | 50 ++++++++++++++--------- src/frontend/api/llm.rs | 26 ++++++------ src/frontend/api/mod.rs | 16 ++++++++ src/frontend/api/projects.rs | 8 ++-- src/frontend/api/run_context.rs | 17 ++++++-- src/frontend/api/sessions.rs | 72 ++++++++++++++++++++------------- src/frontend/api/uploads.rs | 8 ++-- 9 files changed, 139 insertions(+), 78 deletions(-) diff --git a/src/frontend/api/agents.rs b/src/frontend/api/agents.rs index 09b203b..1b2c669 100644 --- a/src/frontend/api/agents.rs +++ b/src/frontend/api/agents.rs @@ -26,7 +26,7 @@ pub async fn get( ) -> Result, ApiError> { let meta = skald_core::agents::load_meta(&id)?; let prompt = skald_core::agents::load_prompt(&id)?; - let all = skald.manager().llm_manager().list_models_info().await; + let all = skald.llm_manager().list_models_info().await; let models = sort_models_for_agent(all, meta.scope.as_deref(), meta.strength); Ok(Json(AgentDetail { meta, prompt, models })) } diff --git a/src/frontend/api/approval.rs b/src/frontend/api/approval.rs index c31f1cb..4a0e364 100644 --- a/src/frontend/api/approval.rs +++ b/src/frontend/api/approval.rs @@ -1,5 +1,5 @@ use axum::{ - Json, + Json, Extension, extract::{Path, State}, }; use serde::Deserialize; @@ -11,7 +11,7 @@ use std::collections::HashSet; use std::sync::Arc; use skald_core::skald::Skald; -use super::ApiError; +use super::{ApiError, guard::AuthUser, require_context}; // ── GET /api/approval/rules ─────────────────────────────────────────────────── @@ -78,14 +78,16 @@ fn default_action() -> String { "approve".to_string() } pub async fn resolve_pending( State(skald): State>, + Extension(auth): Extension, Path(p): Path, Json(body): Json, ) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; if body.action == "reject" { // Pass the raw note; the waiting session builds the canonical message. - skald.inbox().reject(p.request_id, body.note.clone()).await; + ctx.inbox.reject(p.request_id, body.note.clone()).await; } else { - skald.inbox().approve(p.request_id).await; + ctx.inbox.approve(p.request_id).await; } Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action }))) } @@ -96,9 +98,11 @@ pub async fn resolve_pending( pub async fn list_pending( State(skald): State>, -) -> Json { - let pending = skald.inbox().list_pending().await.approvals; - Json(json!(pending)) + Extension(auth): Extension, +) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; + let pending = ctx.inbox.list_pending().await.approvals; + Ok(Json(json!(pending))) } // ── GET /api/approval/tools ─────────────────────────────────────────────────── diff --git a/src/frontend/api/inbox.rs b/src/frontend/api/inbox.rs index 8bf988c..96b9d5e 100644 --- a/src/frontend/api/inbox.rs +++ b/src/frontend/api/inbox.rs @@ -1,5 +1,5 @@ use axum::{ - Json, + Json, Extension, extract::{Path, State}, }; use serde::Deserialize; @@ -7,22 +7,26 @@ use serde_json::{Value, json}; use std::time::Duration; use std::sync::Arc; -use skald_core::skald::Skald; -use super::ApiError; +use skald_core::skald::{Skald, UserContext}; +use super::{ApiError, guard::AuthUser, require_context}; // ── GET /api/inbox ──────────────────────────────────────────────────────────── // // Returns all pending approval requests and clarification requests in a single // response, so the frontend can show a unified Agent Inbox page with one fetch. -pub async fn list(State(skald): State>) -> Json { - let items = skald.inbox().list_pending().await; - Json(json!({ +pub async fn list( + State(skald): State>, + Extension(auth): Extension, +) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; + let items = ctx.inbox.list_pending().await; + Ok(Json(json!({ "total": items.total, "approvals": items.approvals, "clarifications": items.clarifications, "elicitations": items.elicitations, - })) + }))) } // ── POST /api/inbox/approvals/:request_id/resolve ───────────────────────────── @@ -47,17 +51,19 @@ fn default_action() -> String { "approve".to_string() } pub async fn resolve_approval( State(skald): State>, + Extension(auth): Extension, Path(p): Path, Json(body): Json, ) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; // Peek info before resolving so we have session_id and tool metadata for bypass. - let info = skald.approval().get_pending(p.request_id).await; + let info = ctx.approval.get_pending(p.request_id).await; if body.action == "reject" { // Pass the raw note; the waiting session builds the canonical message. - skald.inbox().reject(p.request_id, body.note.clone()).await; + ctx.inbox.reject(p.request_id, body.note.clone()).await; } else { - skald.inbox().approve(p.request_id).await; + ctx.inbox.approve(p.request_id).await; // Apply bypass if requested (only on approve). if let (Some(info), Some(bypass_secs)) = (info, body.bypass_secs) { @@ -72,29 +78,29 @@ pub async fn resolve_approval( match scope { "category" => { if let Some(cat) = info.tool_category { - skald.approval().bypass_session_for_category(info.session_id, cat, duration).await; + ctx.approval.bypass_session_for_category(info.session_id, cat, duration).await; } else { - apply_all_bypass(&skald, info.session_id, duration).await; + apply_all_bypass(&ctx, info.session_id, duration).await; } } "mcp_server" => { if let Some(server) = info.mcp_server { - skald.approval().bypass_session_for_mcp(info.session_id, server, duration).await; + ctx.approval.bypass_session_for_mcp(info.session_id, server, duration).await; } else { - apply_all_bypass(&skald, info.session_id, duration).await; + apply_all_bypass(&ctx, info.session_id, duration).await; } } - _ => apply_all_bypass(&skald, info.session_id, duration).await, + _ => apply_all_bypass(&ctx, info.session_id, duration).await, } } } Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action }))) } -async fn apply_all_bypass(skald: &Skald, session_id: i64, duration: Option) { +async fn apply_all_bypass(ctx: &UserContext, session_id: i64, duration: Option) { match duration { - Some(d) => skald.approval().bypass_session_for(session_id, d).await, - None => skald.approval().bypass_session(session_id).await, + Some(d) => ctx.approval.bypass_session_for(session_id, d).await, + None => ctx.approval.bypass_session(session_id).await, } } @@ -110,13 +116,15 @@ pub struct ClarifyBody { pub async fn resolve_clarification( State(skald): State>, + Extension(auth): Extension, Path(p): Path, Json(body): Json, ) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; if body.answer.trim().is_empty() { return Err(ApiError::bad_request("answer must not be empty")); } - let resolved = skald.inbox().answer(p.request_id, body.answer).await; + let resolved = ctx.inbox.answer(p.request_id, body.answer).await; if resolved { Ok(Json(json!({ "ok": true, "request_id": p.request_id }))) } else { @@ -145,14 +153,16 @@ fn default_elicit_action() -> String { "decline".to_string() } pub async fn resolve_elicitation( State(skald): State>, + Extension(auth): Extension, Path(p): Path, Json(body): Json, ) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; let action = match body.action.as_str() { "accept" | "decline" | "cancel" => body.action.clone(), other => return Err(ApiError::bad_request(format!("invalid action: {other}"))), }; - let resolved = skald.inbox().resolve_elicitation(p.request_id, action.clone(), body.content).await; + let resolved = ctx.inbox.resolve_elicitation(p.request_id, action.clone(), body.content).await; if resolved { Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": action }))) } else { diff --git a/src/frontend/api/llm.rs b/src/frontend/api/llm.rs index 0641ada..354efc5 100644 --- a/src/frontend/api/llm.rs +++ b/src/frontend/api/llm.rs @@ -17,7 +17,7 @@ pub async fn provider_models( State(skald): State>, axum::extract::Path(id): axum::extract::Path, ) -> Result>, ApiError> { - let models = skald.manager().llm_manager().list_provider_models(id).await?; + let models = skald.llm_manager().list_provider_models(id).await?; Ok(Json(models)) } @@ -36,7 +36,7 @@ pub async fn provider_reasoning_mode( axum::extract::Path(id): axum::extract::Path, axum::extract::Query(q): axum::extract::Query, ) -> Json> { - let mode = skald.manager().llm_manager().reasoning_mode_for(id, &q.model_id).await; + let mode = skald.llm_manager().reasoning_mode_for(id, &q.model_id).await; Json(mode) } @@ -51,7 +51,7 @@ pub struct SelectorResponse { pub async fn selector( State(skald): State>, ) -> Result, ApiError> { - let mgr = skald.manager().llm_manager(); + let mgr = skald.llm_manager(); let models = mgr.client_names().await; let default = mgr.default_name().await; Ok(Json(SelectorResponse { models, default })) @@ -62,7 +62,7 @@ pub async fn selector( pub async fn list_providers( State(skald): State>, ) -> Result>, ApiError> { - Ok(Json(skald.manager().llm_manager().list_providers_info().await)) + Ok(Json(skald.llm_manager().list_providers_info().await)) } #[derive(Deserialize)] @@ -94,7 +94,7 @@ pub async fn create_provider( ) -> Result { validate_provider_type(&skald, &payload.provider)?; let record = LlmProviderRecord::from(payload); - skald.manager().llm_manager().add_provider(record).await?; + skald.llm_manager().add_provider(record).await?; Ok(StatusCode::CREATED) } @@ -102,7 +102,7 @@ pub async fn get_provider( State(skald): State>, axum::extract::Path(id): axum::extract::Path, ) -> Result, ApiError> { - skald.manager().llm_manager().get_provider(id).await + skald.llm_manager().get_provider(id).await .map(Json) .ok_or_else(|| ApiError::not_found(format!("provider {id} not found"))) } @@ -114,7 +114,7 @@ pub async fn update_provider( ) -> Result { validate_provider_type(&skald, &payload.provider)?; let record = LlmProviderRecord::from(payload); - skald.manager().llm_manager().update_provider(id, record).await?; + skald.llm_manager().update_provider(id, record).await?; Ok(StatusCode::NO_CONTENT) } @@ -122,7 +122,7 @@ pub async fn delete_provider( State(skald): State>, axum::extract::Path(id): axum::extract::Path, ) -> Result { - skald.manager().llm_manager().delete_provider(id).await?; + skald.llm_manager().delete_provider(id).await?; Ok(StatusCode::NO_CONTENT) } @@ -131,7 +131,7 @@ pub async fn delete_provider( pub async fn list_models( State(skald): State>, ) -> Result>, ApiError> { - let mgr = skald.manager().llm_manager(); + let mgr = skald.llm_manager(); // Warm the catalog cache for every provider concurrently so that price data // is available for the join inside list_models_info(). Errors are ignored — @@ -203,7 +203,7 @@ pub async fn create_model( Json(payload): Json, ) -> Result { let record = LlmModelRecord::try_from(payload)?; - skald.manager().llm_manager().add_model(record).await?; + skald.llm_manager().add_model(record).await?; Ok(StatusCode::CREATED) } @@ -211,7 +211,7 @@ pub async fn get_model( State(skald): State>, axum::extract::Path(id): axum::extract::Path, ) -> Result, ApiError> { - skald.manager().llm_manager().get_model(id).await + skald.llm_manager().get_model(id).await .map(Json) .ok_or_else(|| ApiError::not_found(format!("model {id} not found"))) } @@ -222,7 +222,7 @@ pub async fn update_model( Json(payload): Json, ) -> Result { let record = LlmModelRecord::try_from(payload)?; - skald.manager().llm_manager().update_model(id, record).await?; + skald.llm_manager().update_model(id, record).await?; Ok(StatusCode::NO_CONTENT) } @@ -230,7 +230,7 @@ pub async fn delete_model( State(skald): State>, axum::extract::Path(id): axum::extract::Path, ) -> Result { - skald.manager().llm_manager().delete_model(id).await?; + skald.llm_manager().delete_model(id).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 836c9ee..9e94292 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -184,6 +184,22 @@ impl ApiError { pub fn not_found(msg: impl Into) -> Self { Self { status: StatusCode::NOT_FOUND, message: msg.into() } } + + pub fn unauthorized(msg: impl Into) -> Self { + Self { status: StatusCode::UNAUTHORIZED, message: msg.into() } + } +} + +/// Resolves the authenticated caller's per-user runtime context, or `401` when the +/// user's database is locked (not logged in). Every owner-bound HTTP handler starts +/// here to route reads/writes to the caller's own `{userid}.db` instead of the +/// shared registry pool. The `AuthUser` is injected by [`guard::require_auth`]. +pub async fn require_context( + skald: &Skald, + user_id: &str, +) -> Result, ApiError> { + skald.user_context(user_id).await + .ok_or_else(|| ApiError::unauthorized("session expired — please log in again")) } impl IntoResponse for ApiError { diff --git a/src/frontend/api/projects.rs b/src/frontend/api/projects.rs index fa9d783..e314f97 100644 --- a/src/frontend/api/projects.rs +++ b/src/frontend/api/projects.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use axum::{ - Json, + Json, Extension, extract::{Path, State}, http::StatusCode, }; @@ -11,7 +11,7 @@ use skald_core::db::project_tickets::ProjectTicket; use skald_core::db::projects::Project; use skald_core::run_context::RunContext; use skald_core::skald::Skald; -use super::ApiError; +use super::{ApiError, guard::AuthUser, require_context}; /// Source-id prefix for a project's interactive chat session (e.g. `project-42`). /// A hyphen (not `:`) is used so the id is URL-safe in `/api/{source}/messages`. @@ -284,10 +284,12 @@ pub async fn provisioning_for_source( pub async fn open_session( Path(p): Path, State(skald): State>, + Extension(auth): Extension, ) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; let source = format!("{PROJECT_SOURCE_PREFIX}{}", p.id); let (agent, rc) = provisioning_for_source(&skald, &source).await?; - let session_id = skald.chat_hub() + let session_id = ctx.chat_hub .provision_session(&source, &agent, rc.as_ref(), false) .await?; Ok(Json(SessionResponse { source, session_id })) diff --git a/src/frontend/api/run_context.rs b/src/frontend/api/run_context.rs index fd286ba..ede21fb 100644 --- a/src/frontend/api/run_context.rs +++ b/src/frontend/api/run_context.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use axum::{ - Json, + Json, Extension, extract::{Path, State}, http::StatusCode, }; @@ -9,7 +9,7 @@ use serde::Deserialize; use serde_json::{Value, json}; use skald_core::skald::Skald; -use super::ApiError; +use super::{ApiError, guard::AuthUser, require_context}; // ── Tool Permission Groups ──────────────────────────────────────────────────── @@ -87,12 +87,21 @@ pub struct SessionPath { pub session_id: i64 } /// POST body: the full RunContext object, or JSON `null` to clear the context. pub async fn set_session_run_context( State(skald): State>, + Extension(auth): Extension, Path(p): Path, Json(ctx): Json>, ) -> Result, ApiError> { - skald.run_context_manager().set_session_run_context(p.session_id, ctx.as_ref()).await?; + let uctx = require_context(&skald, &auth.user_id).await?; + // The session row (and its live handler) live in the caller's own pool, so the + // persist + live update both target the user's context. Run-context *definitions* + // (roles) remain instance-wide; only the per-session value is owner data. + skald_core::db::chat_sessions::set_run_context( + &uctx.pool, + p.session_id, + ctx.as_ref().map(|c| c.to_db()).as_deref(), + ).await?; - if let Some(handler) = skald.manager().active_handler(p.session_id).await { + if let Some(handler) = uctx.sessions.active_handler(p.session_id).await { handler.set_run_context(ctx).await; } diff --git a/src/frontend/api/sessions.rs b/src/frontend/api/sessions.rs index d47d126..3da4408 100644 --- a/src/frontend/api/sessions.rs +++ b/src/frontend/api/sessions.rs @@ -3,7 +3,7 @@ use std::future::Future; use std::pin::Pin; use axum::{ - Json, + Json, Extension, extract::{Path, Query, State}, }; use serde::{Deserialize, Serialize}; @@ -13,12 +13,12 @@ use sqlx::SqlitePool; use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources}; use skald_core::db::chat_sessions_stack::SessionStack; use std::sync::Arc; -use skald_core::skald::Skald; +use skald_core::skald::{Skald, UserContext}; use skald_core::session::handler::ApprovalDecision; use skald_core::approval::ApprovalManager; use skald_core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn}; -use super::ApiError; +use super::{ApiError, guard::AuthUser, require_context}; // ── POST /api/sessions — start a new conversation ───────────────────────────── @@ -32,12 +32,14 @@ fn default_source() -> String { "web".to_string() } pub async fn create( State(skald): State>, + Extension(auth): Extension, Query(q): Query, ) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; // Resolve agent + RunContext from the source so project chats reset with the // coordinator agent (not the default `main`), then provision a fresh session. let (agent, rc) = super::projects::provisioning_for_source(&skald, &q.source).await?; - skald.chat_hub().provision_session(&q.source, &agent, rc.as_ref(), true).await?; + ctx.chat_hub.provision_session(&q.source, &agent, rc.as_ref(), true).await?; Ok(Json(json!({}))) } @@ -45,8 +47,10 @@ pub async fn create( pub async fn web_messages( State(skald): State>, + Extension(auth): Extension, ) -> Result>, ApiError> { - messages_for_source(&skald, "web").await + let ctx = require_context(&skald, &auth.user_id).await?; + messages_for_source(&skald, &ctx, "web").await } // ── GET /api/:source/messages ───────────────────────────────────────────────── @@ -56,31 +60,36 @@ pub struct SourcePath { pub source: String } pub async fn source_messages( State(skald): State>, + Extension(auth): Extension, Path(p): Path, ) -> Result>, ApiError> { - messages_for_source(&skald, &p.source).await + let ctx = require_context(&skald, &auth.user_id).await?; + messages_for_source(&skald, &ctx, &p.source).await } -async fn messages_for_source(skald: &Arc, source: &str) -> Result>, ApiError> { - let session_id = match sources::active_session_id(skald.db(), source).await? { +async fn messages_for_source(skald: &Arc, ctx: &UserContext, source: &str) -> Result>, ApiError> { + // History/sessions read from the caller's own pool; the tool registry is a + // global capability, and approval is this user's per-user manager. + let db = &ctx.pool; + let session_id = match sources::active_session_id(db, source).await? { Some(id) => id, None => return Ok(Json(vec![])), }; - let main_stack = match chat_sessions_stack::main_for_session(skald.db(), session_id).await? { + let main_stack = match chat_sessions_stack::main_for_session(db, session_id).await? { Some(s) => s, None => return Ok(Json(vec![])), }; let subagent_map: HashMap = - chat_sessions_stack::all_for_session(skald.db(), session_id) + chat_sessions_stack::all_for_session(db, session_id) .await? .into_iter() .filter_map(|s| s.parent_tool_call_id.map(|tc_id| (tc_id, s))) .collect(); let mut items: Vec = Vec::new(); - build_items(skald.db(), skald.tools(), skald.approval(), &main_stack, &subagent_map, &mut items).await?; + build_items(db, skald.tools(), &ctx.approval, &main_stack, &subagent_map, &mut items).await?; Ok(Json(items)) } @@ -117,9 +126,12 @@ pub struct ResolveToolResponse { /// resolve against the correct session — there is no "current session" scoping. pub async fn resolve_tool( State(skald): State>, + Extension(auth): Extension, Path(p): Path, Json(body): Json, ) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; + let db = &ctx.pool; // Look up the tool call by id alone — no active-session filter. Also pull the // owning session_id so the post-restart path drives the correct session. let tc = sqlx::query_as::<_, (i64, String, Option, String, i64)>( @@ -130,7 +142,7 @@ pub async fn resolve_tool( WHERE t.id = ?", ) .bind(p.tool_call_id) - .fetch_optional(&**skald.db()) + .fetch_optional(&**db) .await? .ok_or_else(|| anyhow::anyhow!( "tool_call_id {} not found", p.tool_call_id @@ -152,12 +164,12 @@ pub async fn resolve_tool( // Pass the raw user note to the live session so the loop builds the // canonical message; for the not-live path (no waiting session, e.g. // after a restart) build the same message here and save it directly. - let live = skald.approval() + let live = ctx.approval .resolve_for_tool_call(tc_id, ApprovalDecision::Rejected { note: body.note.clone() }) .await; let msg = ApprovalDecision::rejection_message(&body.note); if !live { - chat_llm_tools::reject(skald.db(), tc_id, &msg).await?; + chat_llm_tools::reject(db, tc_id, &msg).await?; } return Ok(Json(ResolveToolResponse { tool_call_id: tc_id, @@ -169,7 +181,7 @@ pub async fn resolve_tool( // `restart` calls process::exit — mark done in DB first. if tc_name == tn::RESTART { - chat_llm_tools::complete(skald.db(), tc_id, "Riavvio avviato.", "string").await?; + chat_llm_tools::complete(db, tc_id, "Riavvio avviato.", "string").await?; // Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in // whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134 // instead of 255 — breaking the run.sh restart supervisor). @@ -177,7 +189,7 @@ pub async fn resolve_tool( } // ── Live path: LLM loop is blocked waiting for approval ────────────────── - if skald.approval() + if ctx.approval .resolve_for_tool_call(tc_id, ApprovalDecision::Approved) .await { @@ -196,9 +208,9 @@ pub async fn resolve_tool( // via `execute_tool_call` (gate skipped) and continues the loop. Events stream // to the reconnected client through the global bus; return immediately. if tc_name == "execute_task" || tc_name == tn::EXECUTE_SUBTASK || tc_name == "run_subtask" { - let handler = skald.chat_hub().handler_for_session(session_id).await?; + let handler = ctx.chat_hub.handler_for_session(session_id).await?; handler.mark_pre_approved(tc_id); - let hub = skald.chat_hub().clone(); + let hub = ctx.chat_hub.clone(); tokio::spawn(async move { if let Err(e) = hub.resume_session(session_id).await { tracing::warn!(session_id, tool_call_id = tc_id, error = %e, "post-restart resume of sub-agent tool failed"); @@ -213,12 +225,12 @@ pub async fn resolve_tool( } // Simple tools: execute directly on the owning session and return the result. - let handler = skald.chat_hub().handler_for_session(session_id).await?; + let handler = ctx.chat_hub.handler_for_session(session_id).await?; match handler.execute_tool(&tc_name, args).await { Ok(result) => { let wire = result.to_wire(); let kind = result.kind(); - chat_llm_tools::complete(skald.db(), tc_id, &wire, kind).await?; + chat_llm_tools::complete(db, tc_id, &wire, kind).await?; Ok(Json(ResolveToolResponse { tool_call_id: tc_id, status: "done".to_string(), @@ -228,7 +240,7 @@ pub async fn resolve_tool( } Err(e) => { let msg = e.to_string(); - chat_llm_tools::fail(skald.db(), tc_id, &msg).await?; + chat_llm_tools::fail(db, tc_id, &msg).await?; Err(anyhow::anyhow!(msg).into()) } } @@ -250,8 +262,11 @@ fn default_per_page() -> i64 { 20 } pub async fn list_sessions( State(skald): State>, + Extension(auth): Extension, Query(q): Query, ) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; + let db = &ctx.pool; let per_page = q.per_page.max(1).min(100); let offset = ((q.page.max(1)) - 1) * per_page; let src = q.source.as_deref(); @@ -261,7 +276,7 @@ pub async fn list_sessions( WHERE (? IS NULL OR cs.source = ?)", ) .bind(src).bind(src) - .fetch_one(&**skald.db()).await?; + .fetch_one(&**db).await?; let rows = sqlx::query_as::<_, (i64, String, String, bool, bool, Option, i64, Option)>( "SELECT cs.id, cs.source, cs.agent_id, cs.is_ephemeral, cs.is_interactive, @@ -278,7 +293,7 @@ pub async fn list_sessions( ) .bind(src).bind(src) .bind(per_page).bind(offset) - .fetch_all(&**skald.db()).await?; + .fetch_all(&**db).await?; let items: Vec = rows.into_iter().map(|(id, source, agent_id, is_ephemeral, is_interactive, created_at, message_count, last_message_at)| { json!({ @@ -308,9 +323,12 @@ pub struct SessionIdPath { pub id: i64 } pub async fn get_session_detail( State(skald): State>, + Extension(auth): Extension, Path(p): Path, ) -> Result, ApiError> { - let session = chat_sessions::find_by_id(skald.db(), p.id) + let ctx = require_context(&skald, &auth.user_id).await?; + let db = &ctx.pool; + let session = chat_sessions::find_by_id(db, p.id) .await? .ok_or_else(|| ApiError::not_found(format!("session {} not found", p.id)))?; @@ -318,10 +336,10 @@ pub async fn get_session_detail( "SELECT created_at FROM chat_sessions WHERE id = ?", ) .bind(p.id) - .fetch_optional(&**skald.db()) + .fetch_optional(&**db) .await?; - let all_stacks = chat_sessions_stack::all_for_session(skald.db(), session.id).await?; + let all_stacks = chat_sessions_stack::all_for_session(db, session.id).await?; let subagent_map: HashMap = all_stacks .iter() @@ -340,7 +358,7 @@ pub async fn get_session_detail( }; let mut messages: Vec = Vec::new(); - build_debug_items(skald.db(), skald.tools(), &main_stack, &subagent_map, &mut messages).await?; + build_debug_items(db, skald.tools(), &main_stack, &subagent_map, &mut messages).await?; Ok(Json(json!({ "session": { diff --git a/src/frontend/api/uploads.rs b/src/frontend/api/uploads.rs index 6ab4d91..cce257d 100644 --- a/src/frontend/api/uploads.rs +++ b/src/frontend/api/uploads.rs @@ -2,7 +2,7 @@ use std::path::{Path as StdPath, PathBuf}; use std::sync::Arc; use axum::{ - Json, + Json, Extension, extract::{Multipart, Path, State}, }; use tokio::io::AsyncWriteExt; @@ -11,7 +11,7 @@ use core_api::message_meta::Attachment; use skald_core::skald::Skald; use skald_core::tools::fs as fs_tools; -use super::ApiError; +use super::{ApiError, guard::AuthUser, require_context}; use super::sessions::SourcePath; /// `POST /api/{source}/uploads` @@ -25,12 +25,14 @@ use super::sessions::SourcePath; /// size) so the client can show chips and echo them back when sending the message. pub async fn upload( State(skald): State>, + Extension(auth): Extension, Path(p): Path, mut multipart: Multipart, ) -> Result>, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; // Resolve (creating if needed) the source's session so uploads land in the // directory the message will reference. - let session_id = skald.chat_hub().session_handler(&p.source).await?.session_id; + let session_id = ctx.chat_hub.session_handler(&p.source).await?.session_id; let dir_rel = format!("data/uploads/{session_id}"); let dir_abs = fs_tools::resolve(&dir_rel)?;