Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ assert_matches = "1.5"
rstest = "0.26"
futures = "0.3"
chrono = "0.4"
# OpenAPI spec generation (issue #241). `axum_extras` enables richer
# parameter inference for axum `Query`/`Path` extractors.
utoipa = { version = "5", features = ["axum_extras"] }

# Miden
miden-protocol = "0.14.5"
Expand Down
1 change: 1 addition & 0 deletions crates/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ tonic-reflection = "0.14"
prost = { workspace = true }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
utoipa = { workspace = true }
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
Expand Down
147 changes: 137 additions & 10 deletions crates/server/src/api/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,25 @@ use crate::services::{
};
use crate::state::AppState;

#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ChallengeQuery {
pub commitment: String,
}

#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct VerifyOperatorRequest {
pub commitment: String,
pub signature: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct OperatorChallengeResponse {
pub success: bool,
pub challenge: OperatorChallengeView,
}

#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct OperatorChallengeView {
pub domain: String,
pub commitment: String,
Expand All @@ -45,14 +46,14 @@ pub struct OperatorChallengeView {
pub signing_digest: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct VerifyOperatorResponse {
pub success: bool,
pub operator_id: String,
pub expires_at: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LogoutOperatorResponse {
pub success: bool,
}
Expand All @@ -73,7 +74,8 @@ pub struct DashboardAccountsResponse {
}

/// `?limit=&cursor=` query parameters for the paginated account list.
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct AccountsQuery {
#[serde(default)]
pub limit: Option<String>,
Expand All @@ -83,6 +85,18 @@ pub struct AccountsQuery {
pub paused: Option<bool>,
}

/// Issue a login challenge for an operator commitment. The operator
/// signs the returned `signing_digest` and submits it to `/auth/verify`.
#[utoipa::path(
get,
path = "/auth/challenge",
tag = "dashboard",
params(ChallengeQuery),
responses(
(status = 200, description = "Challenge issued", body = OperatorChallengeResponse),
(status = 400, description = "Invalid commitment", body = crate::openapi::ApiErrorResponse),
)
)]
pub async fn challenge_operator_login(
State(state): State<AppState>,
Query(query): Query<ChallengeQuery>,
Expand All @@ -104,6 +118,18 @@ pub async fn challenge_operator_login(
}))
}

/// Verify a signed login challenge and establish an operator session
/// (sets a session cookie on success).
#[utoipa::path(
post,
path = "/auth/verify",
tag = "dashboard",
request_body = VerifyOperatorRequest,
responses(
(status = 200, description = "Session established", body = VerifyOperatorResponse),
(status = 401, description = "Challenge verification failed", body = crate::openapi::ApiErrorResponse),
)
)]
pub async fn verify_operator_login(
State(state): State<AppState>,
Json(payload): Json<VerifyOperatorRequest>,
Expand All @@ -124,6 +150,15 @@ pub async fn verify_operator_login(
))
}

/// Invalidate the current operator session and clear the session cookie.
#[utoipa::path(
post,
path = "/auth/logout",
tag = "dashboard",
responses(
(status = 200, description = "Session invalidated", body = LogoutOperatorResponse),
)
)]
pub async fn logout_operator(
State(state): State<AppState>,
headers: HeaderMap,
Expand All @@ -141,6 +176,20 @@ pub async fn logout_operator(
)
}

/// Paginated list of accounts visible to the operator. Requires the
/// `dashboard:read` permission and a valid operator session.
#[utoipa::path(
get,
path = "/dashboard/accounts",
tag = "dashboard",
params(AccountsQuery),
responses(
(status = 200, description = "Account page", body = PagedResult<DashboardAccountSummary>),
(status = 400, description = "Invalid limit or cursor", body = crate::openapi::ApiErrorResponse),
(status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse),
(status = 403, description = "Missing dashboard:read permission", body = crate::openapi::ApiErrorResponse),
)
)]
pub async fn list_operator_accounts(
State(state): State<AppState>,
Extension(_operator): Extension<AuthenticatedOperator>,
Expand All @@ -158,6 +207,16 @@ pub async fn list_operator_accounts(

/// `GET /dashboard/info` — point-in-time inventory and lifecycle
/// summary per feature `005-operator-dashboard-metrics` US2.
#[utoipa::path(
get,
path = "/dashboard/info",
tag = "dashboard",
responses(
(status = 200, description = "Inventory and lifecycle summary", body = DashboardInfoResponse),
(status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse),
(status = 403, description = "Missing dashboard:read permission", body = crate::openapi::ApiErrorResponse),
)
)]
pub async fn get_dashboard_info_handler(
State(state): State<AppState>,
Extension(_operator): Extension<AuthenticatedOperator>,
Expand All @@ -166,14 +225,25 @@ pub async fn get_dashboard_info_handler(
Ok(Json(info))
}

#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SessionInfoResponse {
pub operator_id: String,
pub permissions: Vec<String>,
}

// `GET /dashboard/session` — session introspection (US6 / FR-033..FR-036).
// Bypasses the authz layer so `permissions: []` operators get 200 + [], not 403.
/// Introspect the current operator session: operator id and the
/// lex-ordered set of effective permissions.
#[utoipa::path(
get,
path = "/dashboard/session",
tag = "dashboard",
responses(
(status = 200, description = "Session info", body = SessionInfoResponse),
(status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse),
)
)]
pub async fn get_dashboard_session_handler(
Extension(operator): Extension<AuthenticatedOperator>,
) -> Json<SessionInfoResponse> {
Expand All @@ -189,6 +259,19 @@ pub async fn get_dashboard_session_handler(
})
}

/// Fetch the detail view for one account.
#[utoipa::path(
get,
path = "/dashboard/accounts/{account_id}",
tag = "dashboard",
params(("account_id" = String, Path, description = "Account identifier")),
responses(
(status = 200, description = "Account detail", body = DashboardAccountDetail),
(status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse),
(status = 403, description = "Missing dashboard:read permission", body = crate::openapi::ApiErrorResponse),
(status = 503, description = "Account state unavailable", body = crate::openapi::ApiErrorResponse),
)
)]
pub async fn get_operator_account(
State(state): State<AppState>,
Extension(_operator): Extension<AuthenticatedOperator>,
Expand All @@ -198,6 +281,19 @@ pub async fn get_operator_account(
Ok(Json(response.account))
}

/// Decode and return the current vault snapshot for a Miden account.
#[utoipa::path(
get,
path = "/dashboard/accounts/{account_id}/snapshot",
tag = "dashboard",
params(("account_id" = String, Path, description = "Account identifier")),
responses(
(status = 200, description = "Account snapshot", body = DashboardAccountSnapshot),
(status = 400, description = "Unsupported for this network (e.g. EVM)", body = crate::openapi::ApiErrorResponse),
(status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse),
(status = 403, description = "Missing dashboard:read permission", body = crate::openapi::ApiErrorResponse),
)
)]
pub async fn get_operator_account_snapshot(
State(state): State<AppState>,
Extension(_operator): Extension<AuthenticatedOperator>,
Expand All @@ -207,17 +303,33 @@ pub async fn get_operator_account_snapshot(
Ok(Json(snapshot))
}

#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct PauseAccountRequest {
pub reason: String,
}

#[derive(Debug, Deserialize, Default)]
#[derive(Debug, Deserialize, Default, utoipa::ToSchema)]
pub struct UnpauseAccountRequest {
#[serde(default)]
pub reason: Option<String>,
}

/// Pause an account (blocks mutating actions). Requires the
/// `accounts:pause` permission.
#[utoipa::path(
post,
path = "/dashboard/accounts/{account_id}/pause",
tag = "dashboard",
params(("account_id" = String, Path, description = "Account identifier")),
request_body = PauseAccountRequest,
responses(
(status = 200, description = "Account paused", body = PauseResponse),
(status = 400, description = "Invalid or missing reason", body = crate::openapi::ApiErrorResponse),
(status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse),
(status = 403, description = "Missing accounts:pause permission", body = crate::openapi::ApiErrorResponse),
(status = 404, description = "Account not found", body = crate::openapi::ApiErrorResponse),
)
)]
pub async fn pause_account_handler(
State(state): State<AppState>,
Extension(operator): Extension<AuthenticatedOperator>,
Expand All @@ -236,6 +348,21 @@ pub async fn pause_account_handler(
Ok(Json(response))
}

/// Unpause an account (idempotent). Requires the `accounts:pause`
/// permission. Accepts an optional reason in the body.
#[utoipa::path(
post,
path = "/dashboard/accounts/{account_id}/unpause",
tag = "dashboard",
params(("account_id" = String, Path, description = "Account identifier")),
request_body = UnpauseAccountRequest,
responses(
(status = 200, description = "Account unpaused", body = UnpauseResponse),
(status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse),
(status = 403, description = "Missing accounts:pause permission", body = crate::openapi::ApiErrorResponse),
(status = 404, description = "Account not found", body = crate::openapi::ApiErrorResponse),
)
)]
pub async fn unpause_account_handler(
State(state): State<AppState>,
Extension(operator): Extension<AuthenticatedOperator>,
Expand Down
Loading
Loading