diff --git a/.codex b/.codex new file mode 100644 index 00000000..e69de29b diff --git a/Cargo.lock b/Cargo.lock index 36fcd60c..dec752ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1767,9 +1767,9 @@ dependencies = [ [[package]] name = "mostro-core" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb156bc9ea69782afd522c2b8a60a87765c75c336cfc4c0360ac569bc9bda42" +checksum = "08956454941d9577a2cde63ae6ee5d8bac4ffeee8398d02aa844ad4b3638723a" dependencies = [ "bitcoin", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 48977e8c..7e266da6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ reqwest = { version = "0.12.1", default-features = false, features = [ "json", "rustls-tls", ] } -mostro-core = { version = "0.8.3", features = ["sqlx"] } +mostro-core = { version = "0.8.4", features = ["sqlx"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } clap = { version = "4.5.45", features = ["derive"] } diff --git a/docs/ADMIN_RPC_AND_DISPUTES.md b/docs/ADMIN_RPC_AND_DISPUTES.md index 3b65445c..83e292bb 100644 --- a/docs/ADMIN_RPC_AND_DISPUTES.md +++ b/docs/ADMIN_RPC_AND_DISPUTES.md @@ -10,8 +10,8 @@ Admin capabilities and dispute resolution paths. ## Dispute Lifecycle - Open: `src/app/dispute.rs` (Action=Dispute) → mark order as `Dispute` and notify. -- Admin Take: `src/app/admin_take_dispute.rs` assigns solver. -- Admin Settle: `src/app/admin_settle.rs` settles/cancels hold or pays out as needed. +- Admin Take: `src/app/admin_take_dispute.rs` assigns solver. Both `read` and `read-write` solvers may take disputes. +- Admin Settle: `src/app/admin_settle.rs` settles/cancels hold or pays out as needed. Requires a `read-write` solver. ## Admin Cancel - File: `src/app/admin_cancel.rs`. @@ -42,5 +42,6 @@ sequenceDiagram ## Audit and Safety - Require admin authentication/authorization at message level. +- Enforce solver permission levels in the daemon: `read` solvers can assist but cannot execute `admin-settle` or `admin-cancel`. - Record solver, timestamps, and decisions in DB for traceability. - Avoid leaking sensitive data in logs; scrub invoices and keys. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 515cb88f..c2cc3d0e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -62,7 +62,7 @@ flowchart LR - `app/orders.rs` – queries and returns order listings/history. - `app/restore_session.rs` – rehydrates context for a client after reconnect. - `app/trade_pubkey.rs` – exchanges/updates trade pubkeys for secure comms. -- Admin modules – force cancel/settle, take disputes, add solvers; guarded and auditable. +- Admin modules – force cancel/settle, take disputes, add solvers; guarded, auditable, and permission-gated for solver capabilities. ## Configuration Constants (src/config/constants.rs) diff --git a/docs/ORDERS_AND_ACTIONS.md b/docs/ORDERS_AND_ACTIONS.md index 2cb32ca9..92075057 100644 --- a/docs/ORDERS_AND_ACTIONS.md +++ b/docs/ORDERS_AND_ACTIONS.md @@ -36,9 +36,9 @@ Summary of order lifecycle and key handlers. | `orders` | https://github.com/MostroP2P/mostro/blob/main/src/app/orders.rs | Return order lists/history to requester | | `trade-pubkey` | https://github.com/MostroP2P/mostro/blob/main/src/app/trade_pubkey.rs | Exchange or update per-trade pubkeys | | `restore-session` | https://github.com/MostroP2P/mostro/blob/main/src/app/restore_session.rs | Rehydrate client session and state | -| `admin-cancel` | https://github.com/MostroP2P/mostro/blob/main/src/app/admin_cancel.rs | Admin cancel; optionally cancel hold invoice | -| `admin-settle` | https://github.com/MostroP2P/mostro/blob/main/src/app/admin_settle.rs | Admin settlement; settle/cancel holds, finalize | -| `admin-add-solver` | https://github.com/MostroP2P/mostro/blob/main/src/app/admin_add_solver.rs | Register dispute solver key | +| `admin-cancel` | https://github.com/MostroP2P/mostro/blob/main/src/app/admin_cancel.rs | Admin cancel; optionally cancel hold invoice. Assigned solver must have `read-write` permission | +| `admin-settle` | https://github.com/MostroP2P/mostro/blob/main/src/app/admin_settle.rs | Admin settlement; settle/cancel holds, finalize. Assigned solver must have `read-write` permission | +| `admin-add-solver` | https://github.com/MostroP2P/mostro/blob/main/src/app/admin_add_solver.rs | Register dispute solver key. `admin-add-solver` accepts a bare pubkey (defaults to read-write), `pubkey:read`, `pubkey:write` (alias for read-write), or `pubkey:read-write`; see the parser in `admin_add_solver.rs` for the accepted forms | | `admin-take-dispute` | https://github.com/MostroP2P/mostro/blob/main/src/app/admin_take_dispute.rs | Assign or take ownership of dispute | | `last-trade-index` | https://github.com/MostroP2P/mostro/blob/main/src/app/last_trade_index.rs | Retrieve user's last trade index from database | diff --git a/docs/README.md b/docs/README.md index e8e220b5..5d2cded2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,3 +14,5 @@ Quick links to architecture and feature guides. Tips - Run tests and lints before pushing: `cargo test`, `cargo fmt`, `cargo clippy --all-targets --all-features`. - Update SQLx offline data after query/schema changes: `cargo sqlx prepare -- --bin mostrod`. + +- [Solver Permission Levels](./SOLVER_PERMISSION_LEVELS.md) diff --git a/docs/SOLVER_PERMISSION_LEVELS.md b/docs/SOLVER_PERMISSION_LEVELS.md new file mode 100644 index 00000000..60552cc0 --- /dev/null +++ b/docs/SOLVER_PERMISSION_LEVELS.md @@ -0,0 +1,86 @@ +# Solver Permission Levels + +This document specifies solver permission levels for dispute resolution. + +## Summary + +Mostro supports two solver permission levels: + +- `read`: solver can take a dispute, receive dispute context, and communicate with users +- `read-write`: solver can do everything above and can also execute `admin-settle` and `admin-cancel` + +This split is intended to support automated dispute assistants, including AI-based agents, without giving them authority to move funds. + +## Goals + +- allow non-human dispute assistants to participate safely +- enforce authorization at the daemon level, not in prompts or UI +- preserve backward compatibility for existing solver registration flows + +## Data Model + +The `users.category` field is used to represent solver permissions: + +- `0`: regular user / no solver permissions +- `1`: solver with `read` permission only +- `2`: solver with `read-write` permission + +The legacy `is_solver` flag still indicates whether the user is a solver at all. + +## Authorization Rules + +### `admin-take-dispute` +Allowed for: +- Mostro daemon admin key while dispute status is `initiated` or `in-progress` +- solvers with `is_solver = true` while dispute status is `initiated` + +Both `read` and `read-write` solvers may take a dispute. + +### `admin-settle` +Allowed only when: +- the caller is the solver assigned to the dispute +- and the assigned solver has `category = 2` + +If the caller is assigned but only has `read` permission, Mostro returns: +- `CantDoReason::NotAuthorized` + +### `admin-cancel` +Allowed only when: +- the caller is the solver assigned to the dispute +- and the assigned solver has `category = 2` + +If the caller is assigned but only has `read` permission, Mostro returns: +- `CantDoReason::NotAuthorized` + +## AdminAddSolver payload + +`admin-add-solver` continues using `Payload::TextMessage`, but now supports an optional permission suffix. + +Formats: + +- `npub1...` → defaults to `read-write` +- `npub1...:read` → registers solver as read-only +- `npub1...:read-write` → registers solver as read-write +- `npub1...:write` → alias for read-write + +Invalid suffixes must be rejected with `CantDoReason::InvalidParameters`. + +## RPC impact + +The current RPC `AddSolverRequest` still only exposes `solver_pubkey`. + +That means RPC registration remains backward compatible and defaults to `read-write` until the protobuf/API is extended. + +## Dependency + +This feature requires `mostro-core >= 0.8.4` because it uses `CantDoReason::NotAuthorized`. + +## Security rationale + +The key security property is that read-only solvers can never execute dispute-closing actions, even if: + +- a UI exposes the wrong button +- an operator misconfigures an agent prompt +- a remote tool attempts to call `admin-settle` or `admin-cancel` directly + +The daemon enforces the permission boundary. diff --git a/migrations/20260417145500_solver_category_backfill.sql b/migrations/20260417145500_solver_category_backfill.sql new file mode 100644 index 00000000..c6e8b924 --- /dev/null +++ b/migrations/20260417145500_solver_category_backfill.sql @@ -0,0 +1,12 @@ +-- Backfill legacy solver records so pre-existing solvers keep their historical +-- settle/cancel authority after solver permission categories are enforced. +-- +-- Before PR #708, solver capability was represented only by `is_solver = 1`. +-- After PR #708, settle/cancel paths require `category = 2` (read-write). +-- +-- Operators should not need to patch this manually, so migrate any legacy solver +-- rows that still have the default/legacy category value to read-write. +UPDATE users +SET category = 2 +WHERE is_solver = 1 + AND category = 0; diff --git a/src/app/admin_add_solver.rs b/src/app/admin_add_solver.rs index 000c98d8..e090d2dc 100644 --- a/src/app/admin_add_solver.rs +++ b/src/app/admin_add_solver.rs @@ -7,6 +7,43 @@ use nostr::nips::nip59::UnwrappedGift; use nostr_sdk::prelude::*; use tracing::{error, info}; +pub const SOLVER_CATEGORY_READ_ONLY: i64 = 1; +pub const SOLVER_CATEGORY_READ_WRITE: i64 = 2; + +fn parse_solver_payload(payload: &Payload) -> Result<(String, i64), MostroError> { + let raw = match payload { + Payload::TextMessage(p) => p.trim(), + _ => return Err(MostroCantDo(CantDoReason::InvalidTextMessage)), + }; + + if raw.is_empty() { + return Err(MostroCantDo(CantDoReason::InvalidTextMessage)); + } + + let mut parts = raw.split(':'); + let npub = parts + .next() + .ok_or(MostroCantDo(CantDoReason::InvalidTextMessage))? + .trim(); + + if npub.is_empty() { + return Err(MostroCantDo(CantDoReason::InvalidTextMessage)); + } + + let category = match parts.next().map(str::trim) { + None => SOLVER_CATEGORY_READ_WRITE, + Some("read") => SOLVER_CATEGORY_READ_ONLY, + Some("read-write") | Some("write") => SOLVER_CATEGORY_READ_WRITE, + Some("") | Some(_) => return Err(MostroCantDo(CantDoReason::InvalidParameters)), + }; + + if parts.next().is_some() { + return Err(MostroCantDo(CantDoReason::InvalidParameters)); + } + + Ok((npub.to_string(), category)) +} + pub async fn admin_add_solver_action( ctx: &AppContext, msg: Message, @@ -14,46 +51,97 @@ pub async fn admin_add_solver_action( my_keys: &Keys, ) -> Result<(), MostroError> { let pool = ctx.pool(); - // Get request id let request_id = msg.get_inner_message_kind().request_id; let inner_message = msg.get_inner_message_kind(); - let payload = if let Some(payload) = &inner_message.payload { - payload - } else { - error!("No pubkey found!"); - return Ok(()); - }; - let npubkey = if let Payload::TextMessage(p) = payload { - p - } else { - error!("No pubkey found!"); - return Ok(()); - }; + let payload = inner_message + .payload + .as_ref() + .ok_or(MostroCantDo(CantDoReason::InvalidTextMessage))?; - // Check if the pubkey is Mostro if event.sender.to_string() != my_keys.public_key().to_string() { - // We create a Message return Err(MostroInternalErr(ServiceError::InvalidPubkey)); } + let trade_index = inner_message.trade_index.unwrap_or(0); - let public_key = PublicKey::from_bech32(npubkey) + let (npubkey, category) = parse_solver_payload(payload)?; + let public_key = PublicKey::from_bech32(&npubkey) .map_err(|_| MostroInternalErr(ServiceError::InvalidPubkey))?; - let user = User::new(public_key.to_string(), 0, 1, 0, 0, trade_index); - // Use CRUD to create user + + let user = User::new(public_key.to_string(), 0, 1, 0, category, trade_index); + match add_new_user(pool, user).await { - Ok(r) => info!("Solver added: {:#?}", r), - Err(ee) => error!("Error creating solver: {:#?}", ee), + Ok(r) => info!("Solver added: {} with category {}", r, category), + Err(ee) => { + error!("Error creating solver: {:#?}", ee); + return Err(MostroInternalErr(ServiceError::DbAccessError( + ee.to_string(), + ))); + } } - // We create a Message for admin + let message = Message::new_dispute(None, request_id, None, Action::AdminAddSolver, None); let message = message .as_json() .map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?; - // Send the message + send_dm(event.rumor.pubkey, my_keys, &message, None) .await .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::{parse_solver_payload, SOLVER_CATEGORY_READ_ONLY, SOLVER_CATEGORY_READ_WRITE}; + use mostro_core::error::CantDoReason; + use mostro_core::message::Payload; + + #[test] + fn parse_solver_payload_defaults_to_read_write() { + let (npub, category) = + parse_solver_payload(&Payload::TextMessage("npub1test".to_string())).unwrap(); + assert_eq!(npub, "npub1test"); + assert_eq!(category, SOLVER_CATEGORY_READ_WRITE); + } + + #[test] + fn parse_solver_payload_accepts_read_only() { + let (_, category) = + parse_solver_payload(&Payload::TextMessage("npub1test:read".to_string())).unwrap(); + assert_eq!(category, SOLVER_CATEGORY_READ_ONLY); + } + + #[test] + fn parse_solver_payload_accepts_read_write_aliases() { + let (_, category) = + parse_solver_payload(&Payload::TextMessage("npub1test:read-write".to_string())) + .unwrap(); + assert_eq!(category, SOLVER_CATEGORY_READ_WRITE); + + let (_, category) = + parse_solver_payload(&Payload::TextMessage("npub1test:write".to_string())).unwrap(); + assert_eq!(category, SOLVER_CATEGORY_READ_WRITE); + } + + #[test] + fn parse_solver_payload_rejects_invalid_permission() { + let err = + parse_solver_payload(&Payload::TextMessage("npub1test:admin".to_string())).unwrap_err(); + assert_eq!( + err, + mostro_core::error::MostroError::MostroCantDo(CantDoReason::InvalidParameters) + ); + } + + #[test] + fn parse_solver_payload_rejects_empty_permission_token() { + let err = + parse_solver_payload(&Payload::TextMessage("npub1test:".to_string())).unwrap_err(); + assert_eq!( + err, + mostro_core::error::MostroError::MostroCantDo(CantDoReason::InvalidParameters) + ); + } +} diff --git a/src/app/admin_cancel.rs b/src/app/admin_cancel.rs index 250a47b2..0dd4509d 100644 --- a/src/app/admin_cancel.rs +++ b/src/app/admin_cancel.rs @@ -2,7 +2,10 @@ use std::borrow::Cow; use std::str::FromStr; use crate::app::context::AppContext; -use crate::db::{find_dispute_by_order_id, is_assigned_solver, is_dispute_taken_by_admin}; +use crate::db::{ + find_dispute_by_order_id, is_assigned_solver, is_dispute_taken_by_admin, + solver_has_write_permission, +}; use crate::lightning::LndConnector; use crate::nip33::{create_platform_tag_values, new_dispute_event}; use crate::util::{enqueue_order_msg, get_order, send_dm, update_order_event}; @@ -70,6 +73,10 @@ pub async fn admin_cancel_action( _ => {} } + if !solver_has_write_permission(pool, &event.sender.to_string(), order.id).await? { + return Err(MostroCantDo(CantDoReason::NotAuthorized)); + } + // Was order cooperatively cancelled? if order.check_status(Status::CooperativelyCanceled).is_ok() { enqueue_order_msg( diff --git a/src/app/admin_settle.rs b/src/app/admin_settle.rs index e2f71d57..e4821e3a 100644 --- a/src/app/admin_settle.rs +++ b/src/app/admin_settle.rs @@ -1,5 +1,8 @@ use crate::app::context::AppContext; -use crate::db::{find_dispute_by_order_id, is_assigned_solver, is_dispute_taken_by_admin}; +use crate::db::{ + find_dispute_by_order_id, is_assigned_solver, is_dispute_taken_by_admin, + solver_has_write_permission, +}; use crate::lightning::LndConnector; use crate::nip33::{create_platform_tag_values, new_dispute_event}; use crate::util::{enqueue_order_msg, get_order, settle_seller_hold_invoice, update_order_event}; @@ -47,6 +50,10 @@ pub async fn admin_settle_action( _ => {} } + if !solver_has_write_permission(pool, &event.sender.to_string(), order.id).await? { + return Err(MostroCantDo(CantDoReason::NotAuthorized)); + } + // Was order cooperatively cancelled? if order.check_status(Status::CooperativelyCanceled).is_ok() { enqueue_order_msg( @@ -206,7 +213,12 @@ mod tests { let admin_error = CantDoReason::DisputeTakenByAdmin; assert_eq!(format!("{:?}", admin_error), "DisputeTakenByAdmin"); + // New error for authenticated callers lacking enough permissions + let unauthorized_error = CantDoReason::NotAuthorized; + assert_eq!(format!("{:?}", unauthorized_error), "NotAuthorized"); + // Verify they are different error types assert_ne!(regular_error, admin_error); + assert_ne!(admin_error, unauthorized_error); } } diff --git a/src/app/admin_take_dispute.rs b/src/app/admin_take_dispute.rs index ced68e80..c0409dee 100644 --- a/src/app/admin_take_dispute.rs +++ b/src/app/admin_take_dispute.rs @@ -1,5 +1,6 @@ +use crate::app::admin_add_solver::SOLVER_CATEGORY_READ_ONLY; use crate::app::context::AppContext; -use crate::db::{find_solver_pubkey, is_user_present}; +use crate::db::{find_solver_pubkey, is_user_present, user_has_solver_write_permission}; use crate::nip33::{create_platform_tag_values, new_dispute_event}; use crate::util::{get_dispute, send_dm}; use mostro_core::prelude::*; @@ -87,28 +88,63 @@ pub async fn pubkey_event_can_solve( pool: &Pool, ev_pubkey: &PublicKey, status: DisputeStatus, + current_solver_pubkey: Option<&str>, my_keys: &Keys, ) -> bool { + let sender_pubkey = ev_pubkey.to_string(); + // Is mostro admin taking dispute? info!( "admin pubkey {} -event pubkey {} ", my_keys.public_key().to_string(), - ev_pubkey.to_string() + sender_pubkey ); - if ev_pubkey.to_string() == my_keys.public_key().to_string() + if sender_pubkey == my_keys.public_key().to_string() && matches!(status, DisputeStatus::InProgress | DisputeStatus::Initiated) { return true; } - // Is a solver taking a dispute - if let Ok(solver) = find_solver_pubkey(pool, ev_pubkey.to_string()).await { - if solver.is_solver != 0_i64 && status == DisputeStatus::Initiated { - return true; - } + // Sender must be a solver user + let Ok(solver) = find_solver_pubkey(pool, sender_pubkey.clone()).await else { + return false; + }; + if solver.is_solver == 0_i64 { + return false; + } + + // Any solver can pick up a freshly initiated dispute + if status == DisputeStatus::Initiated { + return true; + } + + // Takeover only applies to InProgress disputes + if status != DisputeStatus::InProgress { + return false; + } + + // The currently assigned solver can always continue acting on the dispute + let Some(current_solver_pubkey) = current_solver_pubkey else { + return false; + }; + if current_solver_pubkey == sender_pubkey { + return true; + } + + // Takeover path: a write-capable solver may take over from a read-only solver + let sender_can_write = user_has_solver_write_permission(pool, sender_pubkey.as_str()) + .await + .unwrap_or(false); + if !sender_can_write { + return false; } - false + let Ok(current_solver) = find_solver_pubkey(pool, current_solver_pubkey.to_string()).await + else { + return false; + }; + + current_solver.is_solver != 0_i64 && current_solver.category == SOLVER_CATEGORY_READ_ONLY } pub async fn admin_take_dispute_action( @@ -126,7 +162,15 @@ pub async fn admin_take_dispute_action( // Check if the pubkey is a solver or admin if let Ok(dispute_status) = DisputeStatus::from_str(&dispute.status) { - if !pubkey_event_can_solve(pool, &event.sender, dispute_status, mostro_keys).await { + if !pubkey_event_can_solve( + pool, + &event.sender, + dispute_status, + dispute.solver_pubkey.as_deref(), + mostro_keys, + ) + .await + { // We create a Message return Err(MostroCantDo(CantDoReason::InvalidPubkey)); } @@ -256,3 +300,132 @@ pub async fn admin_take_dispute_action( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::admin_add_solver::{SOLVER_CATEGORY_READ_ONLY, SOLVER_CATEGORY_READ_WRITE}; + use crate::db::add_new_user; + use mostro_core::user::User; + use sqlx::SqlitePool; + + async fn create_test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!().run(&pool).await.unwrap(); + pool + } + + async fn insert_solver(pool: &SqlitePool, pubkey: &str, category: i64) { + add_new_user(pool, User::new(pubkey.to_string(), 0, 1, 0, category, 0)) + .await + .unwrap(); + } + + #[tokio::test] + async fn write_solver_can_take_over_inprogress_from_read_only_solver() { + let pool = create_test_pool().await; + let mostro_keys = Keys::generate(); + let read_only_keys = Keys::generate(); + let write_keys = Keys::generate(); + + insert_solver( + &pool, + &read_only_keys.public_key().to_string(), + SOLVER_CATEGORY_READ_ONLY, + ) + .await; + insert_solver( + &pool, + &write_keys.public_key().to_string(), + SOLVER_CATEGORY_READ_WRITE, + ) + .await; + + let current_solver_pubkey = read_only_keys.public_key().to_string(); + let can_solve = pubkey_event_can_solve( + &pool, + &write_keys.public_key(), + DisputeStatus::InProgress, + Some(current_solver_pubkey.as_str()), + &mostro_keys, + ) + .await; + + assert!( + can_solve, + "a write-capable solver must be able to take over an InProgress dispute currently assigned to a read-only solver" + ); + } + + #[tokio::test] + async fn read_only_solver_cannot_take_over_inprogress_from_read_only_solver() { + let pool = create_test_pool().await; + let mostro_keys = Keys::generate(); + let current_keys = Keys::generate(); + let other_keys = Keys::generate(); + + insert_solver( + &pool, + ¤t_keys.public_key().to_string(), + SOLVER_CATEGORY_READ_ONLY, + ) + .await; + insert_solver( + &pool, + &other_keys.public_key().to_string(), + SOLVER_CATEGORY_READ_ONLY, + ) + .await; + + let current_solver_pubkey = current_keys.public_key().to_string(); + let can_solve = pubkey_event_can_solve( + &pool, + &other_keys.public_key(), + DisputeStatus::InProgress, + Some(current_solver_pubkey.as_str()), + &mostro_keys, + ) + .await; + + assert!( + !can_solve, + "a read-only solver must not be able to take over an InProgress dispute from another read-only solver" + ); + } + + #[tokio::test] + async fn write_solver_cannot_take_over_inprogress_from_write_solver() { + let pool = create_test_pool().await; + let mostro_keys = Keys::generate(); + let current_keys = Keys::generate(); + let other_keys = Keys::generate(); + + insert_solver( + &pool, + ¤t_keys.public_key().to_string(), + SOLVER_CATEGORY_READ_WRITE, + ) + .await; + insert_solver( + &pool, + &other_keys.public_key().to_string(), + SOLVER_CATEGORY_READ_WRITE, + ) + .await; + + let current_solver_pubkey = current_keys.public_key().to_string(); + let can_solve = pubkey_event_can_solve( + &pool, + &other_keys.public_key(), + DisputeStatus::InProgress, + Some(current_solver_pubkey.as_str()), + &mostro_keys, + ) + .await; + + assert!( + !can_solve, + "a write-capable solver must not be able to take over an InProgress dispute already held by another write-capable solver" + ); + } +} diff --git a/src/db.rs b/src/db.rs index 4c0d7db4..03c01276 100644 --- a/src/db.rs +++ b/src/db.rs @@ -218,6 +218,161 @@ async fn migrate_remove_token_columns(pool: &SqlitePool) -> Result<(), MostroErr } } +async fn table_column_exists( + pool: &SqlitePool, + table_name: &str, + column_name: &str, +) -> Result { + Ok(sqlx::query_scalar::<_, i32>( + r#" + SELECT COUNT(*) + FROM pragma_table_info(?1) + WHERE name = ?2 + "#, + ) + .bind(table_name) + .bind(column_name) + .fetch_one(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))? + > 0) +} + +fn parse_duplicate_column_name(err: &sqlx::migrate::MigrateError) -> Option { + let error = err.to_string(); + let marker = "duplicate column name: "; + let column = error.split(marker).nth(1)?.trim(); + Some(column.to_string()) +} + +fn normalize_sql_identifier(token: &str) -> String { + token + .trim() + .trim_end_matches(',') + .trim_matches('"') + .trim_matches('`') + .trim_matches('[') + .trim_matches(']') + .to_string() +} + +fn strip_sql_comments(sql: &str) -> String { + sql.lines() + .filter(|line| !line.trim_start().starts_with("--")) + .collect::>() + .join("\n") +} + +fn parse_add_column_statements(sql: &str) -> Option> { + let sql = strip_sql_comments(sql); + let mut operations = Vec::new(); + + for statement in sql.split(';') { + let statement = statement.trim(); + if statement.is_empty() { + continue; + } + + let tokens: Vec<_> = statement.split_whitespace().collect(); + if tokens.len() < 6 + || !tokens[0].eq_ignore_ascii_case("ALTER") + || !tokens[1].eq_ignore_ascii_case("TABLE") + || !tokens[3].eq_ignore_ascii_case("ADD") + || !tokens[4].eq_ignore_ascii_case("COLUMN") + { + return None; + } + + let table_name = normalize_sql_identifier(tokens[2]); + let column_name = normalize_sql_identifier(tokens[5]); + + if table_name.is_empty() || column_name.is_empty() { + return None; + } + + operations.push((table_name, column_name)); + } + + if operations.is_empty() { + None + } else { + Some(operations) + } +} + +async fn applied_migration_versions(pool: &SqlitePool) -> Result, MostroError> { + sqlx::query_scalar::<_, i64>("SELECT version FROM _sqlx_migrations ORDER BY version") + .fetch_all(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) +} + +async fn reconcile_existing_add_column_migration( + pool: &SqlitePool, + migrator: &sqlx::migrate::Migrator, + duplicate_column: &str, +) -> Result { + let applied_versions = applied_migration_versions(pool).await?; + + for migration in migrator.iter() { + if applied_versions.contains(&migration.version) { + continue; + } + + let Some(operations) = parse_add_column_statements(&migration.sql) else { + continue; + }; + + if !operations + .iter() + .any(|(_, column)| column == duplicate_column) + { + continue; + } + + let mut all_columns_exist = true; + for (table_name, column_name) in &operations { + if !table_column_exists(pool, table_name, column_name).await? { + all_columns_exist = false; + break; + } + } + + if !all_columns_exist { + continue; + } + + sqlx::query( + r#" + INSERT OR IGNORE INTO _sqlx_migrations ( + version, + description, + success, + checksum, + execution_time + ) VALUES (?1, ?2, TRUE, ?3, 0) + "#, + ) + .bind(migration.version) + .bind(&*migration.description) + .bind(&*migration.checksum) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + tracing::warn!( + version = migration.version, + description = %migration.description, + duplicate_column, + "Recorded existing add-column migration as already applied" + ); + + return Ok(true); + } + + Ok(false) +} + pub async fn connect() -> Result>, MostroError> { // Get mostro settings let db_settings = Settings::get_db(); @@ -293,6 +448,33 @@ pub async fn connect() -> Result>, MostroError> { .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + // Run migrations for existing databases too + let migrator = sqlx::migrate!(); + if let Err(e) = migrator.run(&conn).await { + if let Some(duplicate_column) = parse_duplicate_column_name(&e) { + if reconcile_existing_add_column_migration(&conn, &migrator, &duplicate_column) + .await? + { + if let Err(e) = migrator.run(&conn).await { + tracing::error!("Failed to run migrations on existing database: {}", e); + return Err(MostroInternalErr(ServiceError::DbAccessError( + e.to_string(), + ))); + } + } else { + tracing::error!("Failed to run migrations on existing database: {}", e); + return Err(MostroInternalErr(ServiceError::DbAccessError( + e.to_string(), + ))); + } + } else { + tracing::error!("Failed to run migrations on existing database: {}", e); + return Err(MostroInternalErr(ServiceError::DbAccessError( + e.to_string(), + ))); + } + } + // Run legacy column migration for existing databases if let Err(e) = migrate_remove_token_columns(&conn).await { tracing::error!( @@ -781,6 +963,64 @@ pub async fn update_user_rating( Ok(rows_affected > 0) } +/// Returns true only when the given `solver_pubkey` is assigned to the dispute +/// identified by `order_id` (`disputes.solver_pubkey` + `disputes.order_id`) and +/// the matching user row is a solver with read-write permission +/// (`users.is_solver = true` and `users.category = 2`). +pub async fn solver_has_write_permission( + pool: &SqlitePool, + solver_pubkey: &str, + order_id: Uuid, +) -> Result { + let result = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 + FROM disputes d + INNER JOIN users u ON u.pubkey = d.solver_pubkey + WHERE d.solver_pubkey = ?1 + AND d.order_id = ?2 + AND u.is_solver = true + AND u.category = 2 + ) + "#, + ) + .bind(solver_pubkey) + .bind(order_id) + .fetch_one(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + Ok(result) +} + +/// Returns true when `pubkey` corresponds to a solver user with read-write +/// permission (`users.is_solver = true` and `users.category = 2`), independent +/// of any dispute assignment. Use this when the caller is a prospective taker +/// rather than the currently assigned solver. +pub async fn user_has_solver_write_permission( + pool: &SqlitePool, + pubkey: &str, +) -> Result { + let result = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 + FROM users + WHERE pubkey = ?1 + AND is_solver = true + AND category = 2 + ) + "#, + ) + .bind(pubkey) + .fetch_one(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + Ok(result) +} + pub async fn is_assigned_solver( pool: &SqlitePool, solver_pubkey: &str,