From 2575e11ef9698ea6d2a463ac36ac3d8dba1de671 Mon Sep 17 00:00:00 2001 From: Haseeb Rabbani Date: Thu, 4 Jun 2026 14:26:40 -0400 Subject: [PATCH] feat(server): generate OpenAPI spec with utoipa (#241) Integrate utoipa to auto-generate an OpenAPI 3.1 spec from the HTTP handlers and wire models, so API docs stay in sync with the code. - Annotate every HTTP handler (client, dashboard, and feature-gated EVM surfaces) with `#[utoipa::path]` and derive `ToSchema` / `IntoParams` on the request/response/query/model types. - Add `openapi::openapi()` which assembles the document and merges the EVM routes only when the `evm` feature is compiled in. - Serve the spec at `GET /api-docs/openapi.json` (unauthenticated, read-only) and add a `gen-openapi` binary to write it to a file. - Commit the generated `docs/openapi.json` (built with `evm`) and document it in `docs/OPENAPI.md`. 36 operations / 82 schemas. Purely additive: no wire-shape changes, so the Rust/TS clients need no updates. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 34 +- Cargo.toml | 3 + crates/server/Cargo.toml | 1 + crates/server/src/api/dashboard.rs | 147 +- crates/server/src/api/dashboard_feeds.rs | 73 +- crates/server/src/api/evm.rs | 150 +- crates/server/src/api/http.rs | 177 +- crates/server/src/bin/gen-openapi.rs | 30 + crates/server/src/builder/handle.rs | 7 + crates/server/src/delta_object.rs | 8 +- crates/server/src/delta_summary/mod.rs | 30 +- crates/server/src/evm/proposal.rs | 6 +- crates/server/src/lib.rs | 1 + crates/server/src/metadata/auth/mod.rs | 2 +- crates/server/src/metadata/network.rs | 6 +- crates/server/src/openapi.rs | 180 + crates/server/src/services/account_status.rs | 2 +- .../dashboard_account_delta_detail.rs | 2 +- .../src/services/dashboard_account_deltas.rs | 4 +- .../services/dashboard_account_proposals.rs | 2 +- .../services/dashboard_account_snapshot.rs | 8 +- .../server/src/services/dashboard_accounts.rs | 6 +- .../src/services/dashboard_global_deltas.rs | 2 +- .../services/dashboard_global_proposals.rs | 2 +- crates/server/src/services/dashboard_info.rs | 12 +- .../src/services/dashboard_pagination.rs | 2 +- crates/server/src/services/pause_account.rs | 2 +- crates/server/src/services/unpause_account.rs | 2 +- crates/server/src/state_object.rs | 4 +- crates/shared/Cargo.toml | 1 + crates/shared/src/lib.rs | 4 +- docs/OPENAPI.md | 41 + docs/README.md | 6 +- docs/openapi.json | 4852 +++++++++++++++++ 34 files changed, 5712 insertions(+), 97 deletions(-) create mode 100644 crates/server/src/bin/gen-openapi.rs create mode 100644 crates/server/src/openapi.rs create mode 100644 docs/OPENAPI.md create mode 100644 docs/openapi.json diff --git a/Cargo.lock b/Cargo.lock index 882658f5..82626336 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,7 +476,7 @@ dependencies = [ "alloy-rlp", "alloy-serde", "alloy-sol-types", - "itertools 0.13.0", + "itertools 0.14.0", "serde", "serde_json", "serde_with", @@ -629,7 +629,7 @@ checksum = "2035f3c4d6bee20624da2dcf765d469b292398e48d766ffade61b0fcf8b4d45d" dependencies = [ "alloy-json-rpc", "alloy-transport", - "itertools 0.13.0", + "itertools 0.14.0", "reqwest 0.13.3", "serde_json", "tower", @@ -3068,6 +3068,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "utoipa", "uuid", ] @@ -3102,6 +3103,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_json", + "utoipa", ] [[package]] @@ -6037,7 +6039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools 0.10.5", + "itertools 0.14.0", "log", "multimap", "petgraph 0.8.3", @@ -6058,7 +6060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -8379,6 +8381,30 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", +] + [[package]] name = "uuid" version = "1.23.0" diff --git a/Cargo.toml b/Cargo.toml index ccdb7d87..9a0e10cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 218269fd..a7da2577 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -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"] } diff --git a/crates/server/src/api/dashboard.rs b/crates/server/src/api/dashboard.rs index 3e6ac79b..042c1bea 100644 --- a/crates/server/src/api/dashboard.rs +++ b/crates/server/src/api/dashboard.rs @@ -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, @@ -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, } @@ -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, @@ -83,6 +85,18 @@ pub struct AccountsQuery { pub paused: Option, } +/// 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, Query(query): Query, @@ -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, Json(payload): Json, @@ -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, headers: HeaderMap, @@ -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), + (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, Extension(_operator): Extension, @@ -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, Extension(_operator): Extension, @@ -166,7 +225,7 @@ 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, @@ -174,6 +233,17 @@ pub struct SessionInfoResponse { // `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, ) -> Json { @@ -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, Extension(_operator): Extension, @@ -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, Extension(_operator): Extension, @@ -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, } +/// 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, Extension(operator): Extension, @@ -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, Extension(operator): Extension, diff --git a/crates/server/src/api/dashboard_feeds.rs b/crates/server/src/api/dashboard_feeds.rs index b26c4d2f..b0972db0 100644 --- a/crates/server/src/api/dashboard_feeds.rs +++ b/crates/server/src/api/dashboard_feeds.rs @@ -29,7 +29,8 @@ use crate::state::AppState; /// per-account proposals, and global proposals. The global deltas /// feed adds a `status` filter and uses its own /// [`GlobalDeltasQuery`] below. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct FeedQuery { #[serde(default)] pub limit: Option, @@ -39,7 +40,8 @@ pub struct FeedQuery { /// `?include=` query parameter for the per-delta detail endpoint. /// Comma-separated list of opt-in features; unknown tokens are ignored. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct DeltaDetailQuery { #[serde(default)] pub include: Option, @@ -48,7 +50,8 @@ pub struct DeltaDetailQuery { /// `?limit=&cursor=&status=` query parameters for the global delta /// feed (FR-031..FR-035). The `status` parameter is comma-separated /// (e.g. `status=candidate,canonical`). -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct GlobalDeltasQuery { #[serde(default)] pub limit: Option, @@ -62,6 +65,18 @@ pub struct GlobalDeltasQuery { /// per-account delta feed paginated newest-first by `nonce DESC`, /// surfacing only `candidate` / `canonical` / `discarded` statuses /// (pending lives on the proposal queue endpoint). +#[utoipa::path( + get, + path = "/dashboard/accounts/{account_id}/deltas", + tag = "dashboard", + params(("account_id" = String, Path, description = "Account identifier"), FeedQuery), + responses( + (status = 200, description = "Per-account delta feed page", body = PagedResult), + (status = 400, description = "Invalid limit or cursor", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "Account not found", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn list_account_deltas_handler( State(state): State, Path(account_id): Path, @@ -83,6 +98,22 @@ pub async fn list_account_deltas_handler( /// hex, and other non-decimal inputs are rejected with /// [`GuardianError::InvalidInput`]. Unknown account or unknown nonce /// both map to `DeltaNotFound` so the wire body is field-level identical. +#[utoipa::path( + get, + path = "/dashboard/accounts/{account_id}/deltas/{nonce}", + tag = "dashboard", + params( + ("account_id" = String, Path, description = "Account identifier"), + ("nonce" = u64, Path, description = "Canonical base-10 delta nonce"), + DeltaDetailQuery, + ), + responses( + (status = 200, description = "Decoded delta detail", body = DashboardDeltaDetail), + (status = 400, description = "Malformed nonce", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "Delta not found", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn list_account_delta_detail_handler( State(state): State, Path((account_id, nonce_str)): Path<(String, String)>, @@ -147,6 +178,18 @@ fn parse_canonical_nonce(raw: &str) -> Result { /// in-flight multisig proposal queue for one account, paginated /// newest-first by `(nonce DESC, commitment DESC)`. Single-key Miden /// and EVM accounts always return an empty page per FR-017. +#[utoipa::path( + get, + path = "/dashboard/accounts/{account_id}/proposals", + tag = "dashboard", + params(("account_id" = String, Path, description = "Account identifier"), FeedQuery), + responses( + (status = 200, description = "Per-account in-flight proposal queue page", body = PagedResult), + (status = 400, description = "Invalid limit or cursor", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "Account not found", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn list_account_proposals_handler( State(state): State, Path(account_id): Path, @@ -168,6 +211,18 @@ pub async fn list_account_proposals_handler( /// discarded}`. Pending entries live on the proposal feed. /// /// Spec reference: `005-operator-dashboard-metrics` US6, FR-031..FR-035. +#[utoipa::path( + get, + path = "/dashboard/deltas", + tag = "dashboard", + params(GlobalDeltasQuery), + responses( + (status = 200, description = "Cross-account delta feed page", body = PagedResult), + (status = 400, description = "Invalid limit, cursor, or status filter", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse), + (status = 503, description = "Aggregate degraded (filesystem threshold exceeded)", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn list_global_deltas_handler( State(state): State, Query(query): Query, @@ -189,6 +244,18 @@ pub async fn list_global_deltas_handler( /// accounts do not appear in v1 per FR-017. /// /// Spec reference: `005-operator-dashboard-metrics` US7, FR-035..FR-037. +#[utoipa::path( + get, + path = "/dashboard/proposals", + tag = "dashboard", + params(FeedQuery), + responses( + (status = 200, description = "Cross-account in-flight proposal feed page", body = PagedResult), + (status = 400, description = "Invalid limit or cursor", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "No operator session", body = crate::openapi::ApiErrorResponse), + (status = 503, description = "Aggregate degraded (filesystem threshold exceeded)", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn list_global_proposals_handler( State(state): State, Query(query): Query, diff --git a/crates/server/src/api/evm.rs b/crates/server/src/api/evm.rs index d9eee622..ad909727 100644 --- a/crates/server/src/api/evm.rs +++ b/crates/server/src/api/evm.rs @@ -9,46 +9,49 @@ use crate::error::{GuardianError, Result}; use crate::evm::{EvmProposal, ExecutableEvmProposal}; use crate::state::AppState; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct ChallengeQuery { pub address: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ChallengeResponse { pub address: String, pub nonce: String, pub issued_at: i64, pub expires_at: i64, + /// EIP-712 typed-data payload the wallet signs to establish a session. + #[schema(value_type = Object)] pub typed_data: serde_json::Value, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct VerifySessionRequest { pub address: String, pub nonce: String, pub signature: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct VerifySessionResponse { pub address: String, pub expires_at: i64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct LogoutResponse { pub success: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct RegisterAccountRequest { pub chain_id: u64, pub account_address: String, pub multisig_validator_address: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct RegisterAccountResponse { pub account_id: String, pub chain_id: u64, @@ -58,12 +61,13 @@ pub struct RegisterAccountResponse { pub threshold: usize, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct AccountQuery { pub account_id: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateProposalRequest { pub account_id: String, pub user_op_hash: String, @@ -73,27 +77,38 @@ pub struct CreateProposalRequest { pub signature: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ListProposalsResponse { pub proposals: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ApproveProposalRequest { pub account_id: String, pub signature: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CancelProposalRequest { pub account_id: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct CancelProposalResponse { pub success: bool, } +/// Issue an EIP-712 session challenge for an EVM wallet address. +#[utoipa::path( + get, + path = "/evm/auth/challenge", + tag = "evm", + params(ChallengeQuery), + responses( + (status = 200, description = "Challenge issued", body = ChallengeResponse), + (status = 400, description = "Invalid address", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn challenge_evm_session( State(state): State, Query(query): Query, @@ -112,6 +127,18 @@ pub async fn challenge_evm_session( })) } +/// Verify a signed EVM session challenge and establish a session +/// (sets a session cookie on success). +#[utoipa::path( + post, + path = "/evm/auth/verify", + tag = "evm", + request_body = VerifySessionRequest, + responses( + (status = 200, description = "Session established", body = VerifySessionResponse), + (status = 401, description = "Challenge verification failed", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn verify_evm_session( State(state): State, Json(request): Json, @@ -138,6 +165,15 @@ pub async fn verify_evm_session( )) } +/// Invalidate the current EVM session and clear the session cookie. +#[utoipa::path( + post, + path = "/evm/auth/logout", + tag = "evm", + responses( + (status = 200, description = "Session invalidated", body = LogoutResponse), + ) +)] pub async fn logout_evm_session( State(state): State, headers: HeaderMap, @@ -154,6 +190,18 @@ pub async fn logout_evm_session( )) } +/// Register an EVM smart-account with Guardian (requires an EVM session). +#[utoipa::path( + post, + path = "/evm/accounts", + tag = "evm", + request_body = RegisterAccountRequest, + responses( + (status = 200, description = "Account registered", body = RegisterAccountResponse), + (status = 400, description = "Invalid network config", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "Missing EVM session", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn register_evm_account( State(state): State, headers: HeaderMap, @@ -180,6 +228,18 @@ pub async fn register_evm_account( })) } +/// Create a new EVM multisig proposal (requires an EVM session). +#[utoipa::path( + post, + path = "/evm/proposals", + tag = "evm", + request_body = CreateProposalRequest, + responses( + (status = 200, description = "Proposal created", body = EvmProposal), + (status = 400, description = "Invalid proposal input", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "Missing EVM session", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn create_evm_proposal( State(state): State, headers: HeaderMap, @@ -202,6 +262,17 @@ pub async fn create_evm_proposal( Ok(Json(proposal)) } +/// List EVM proposals for an account (requires an EVM session). +#[utoipa::path( + get, + path = "/evm/proposals", + tag = "evm", + params(AccountQuery), + responses( + (status = 200, description = "Proposals", body = ListProposalsResponse), + (status = 401, description = "Missing EVM session", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn list_evm_proposals( State(state): State, headers: HeaderMap, @@ -213,6 +284,18 @@ pub async fn list_evm_proposals( Ok(Json(ListProposalsResponse { proposals })) } +/// Fetch a single EVM proposal by id (requires an EVM session). +#[utoipa::path( + get, + path = "/evm/proposals/{proposal_id}", + tag = "evm", + params(("proposal_id" = String, Path, description = "Proposal identifier"), AccountQuery), + responses( + (status = 200, description = "Proposal", body = EvmProposal), + (status = 401, description = "Missing EVM session", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "Proposal not found", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn get_evm_proposal( State(state): State, headers: HeaderMap, @@ -230,6 +313,20 @@ pub async fn get_evm_proposal( Ok(Json(proposal)) } +/// Add an approval signature to an EVM proposal (requires an EVM session). +#[utoipa::path( + post, + path = "/evm/proposals/{proposal_id}/approve", + tag = "evm", + params(("proposal_id" = String, Path, description = "Proposal identifier")), + request_body = ApproveProposalRequest, + responses( + (status = 200, description = "Approval recorded", body = EvmProposal), + (status = 400, description = "Invalid signature", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "Missing EVM session", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "Proposal not found", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn approve_evm_proposal( State(state): State, headers: HeaderMap, @@ -250,6 +347,20 @@ pub async fn approve_evm_proposal( Ok(Json(proposal)) } +/// Fetch the executable (threshold-met) form of an EVM proposal, +/// ready for on-chain submission (requires an EVM session). +#[utoipa::path( + get, + path = "/evm/proposals/{proposal_id}/executable", + tag = "evm", + params(("proposal_id" = String, Path, description = "Proposal identifier"), AccountQuery), + responses( + (status = 200, description = "Executable proposal", body = ExecutableEvmProposal), + (status = 400, description = "Proposal not yet executable", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "Missing EVM session", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "Proposal not found", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn get_executable_evm_proposal( State(state): State, headers: HeaderMap, @@ -267,6 +378,19 @@ pub async fn get_executable_evm_proposal( Ok(Json(executable)) } +/// Cancel an EVM proposal (requires an EVM session). +#[utoipa::path( + post, + path = "/evm/proposals/{proposal_id}/cancel", + tag = "evm", + params(("proposal_id" = String, Path, description = "Proposal identifier")), + request_body = CancelProposalRequest, + responses( + (status = 200, description = "Proposal cancelled", body = CancelProposalResponse), + (status = 401, description = "Missing EVM session", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "Proposal not found", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn cancel_evm_proposal( State(state): State, headers: HeaderMap, diff --git a/crates/server/src/api/http.rs b/crates/server/src/api/http.rs index d71f8761..3f2ac21e 100644 --- a/crates/server/src/api/http.rs +++ b/crates/server/src/api/http.rs @@ -14,12 +14,14 @@ use guardian_shared::auth_request_payload::AuthRequestPayload; use guardian_shared::{ProposalSignature, SignatureScheme}; use serde::{Deserialize, Serialize}; -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema)] pub struct ConfigureRequest { pub account_id: String, pub auth: Auth, #[serde(skip_serializing_if = "Option::is_none")] pub network_config: Option, + /// Opaque, schema-free JSON blob describing the initial account state. + #[schema(value_type = Object)] pub initial_state: serde_json::Value, } @@ -38,18 +40,21 @@ impl From for ConfigureAccountParams { } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct DeltaQuery { pub account_id: String, pub nonce: u64, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct StateQuery { pub account_id: String, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct LookupQuery { pub key_commitment: String, } @@ -57,35 +62,39 @@ pub struct LookupQuery { /// Single match in a lookup response. Wraps `account_id` so the response shape /// can be extended in a forward-compatible way (e.g. adding role tags or /// per-account metadata) without breaking existing clients. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct LookupAccount { pub account_id: String, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct LookupResponse { pub accounts: Vec, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct ProposalQuery { pub account_id: String, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct ProposalItemQuery { pub account_id: String, pub commitment: String, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema)] pub struct DeltaProposalRequest { pub account_id: String, pub nonce: u64, + /// Opaque, schema-free multisig proposal payload. + #[schema(value_type = Object)] pub delta_payload: serde_json::Value, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema)] pub struct SignProposalRequest { pub account_id: String, pub commitment: String, @@ -93,7 +102,7 @@ pub struct SignProposalRequest { } // Response types -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct ConfigureResponse { pub success: bool, pub message: String, @@ -103,13 +112,25 @@ pub struct ConfigureResponse { pub code: Option<&'static str>, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct ErrorResponse { pub success: bool, pub code: &'static str, pub error: String, } +/// Configure (register) an account with its authorization set and +/// initial state. Requires a signed `X-Guardian-*` auth header. +#[utoipa::path( + post, + path = "/configure", + tag = "client", + request_body = ConfigureRequest, + responses( + (status = 200, description = "Account configured", body = ConfigureResponse), + (status = 400, description = "Invalid request", body = ConfigureResponse), + ) +)] pub async fn configure( State(state): State, AuthHeader(credentials): AuthHeader, @@ -158,6 +179,20 @@ pub async fn configure( } } +/// Push a signed state delta for a single-key account. The request +/// body is the JSON-encoded [`DeltaObject`] to commit. +#[utoipa::path( + post, + path = "/delta", + tag = "client", + request_body = DeltaObject, + responses( + (status = 200, description = "Delta accepted", body = DeltaObject), + (status = 400, description = "Invalid delta payload", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "Authentication failed", body = crate::openapi::ApiErrorResponse), + (status = 409, description = "Conflicting pending delta/proposal", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn push_delta( State(state): State, AuthHeader(credentials): AuthHeader, @@ -178,6 +213,18 @@ pub async fn push_delta( Ok(Json(response.delta)) } +/// Fetch the delta for an account at a specific nonce. +#[utoipa::path( + get, + path = "/delta", + tag = "client", + params(DeltaQuery), + responses( + (status = 200, description = "Delta found", body = DeltaObject), + (status = 401, description = "Authentication failed", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "Delta not found", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn get_delta( State(state): State, AuthHeader(credentials): AuthHeader, @@ -196,6 +243,19 @@ pub async fn get_delta( Ok(Json(response.delta)) } +/// Fetch the merged delta accumulating all changes for an account +/// since (and excluding) the given nonce. +#[utoipa::path( + get, + path = "/delta/since", + tag = "client", + params(DeltaQuery), + responses( + (status = 200, description = "Merged delta", body = DeltaObject), + (status = 401, description = "Authentication failed", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "No deltas found", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn get_delta_since( State(state): State, AuthHeader(credentials): AuthHeader, @@ -214,6 +274,18 @@ pub async fn get_delta_since( Ok(Json(response.merged_delta)) } +/// Fetch the latest canonical state object for an account. +#[utoipa::path( + get, + path = "/state", + tag = "client", + params(StateQuery), + responses( + (status = 200, description = "Current account state", body = StateObject), + (status = 401, description = "Authentication failed", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "State not found", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn get_state( State(state): State, AuthHeader(credentials): AuthHeader, @@ -234,6 +306,16 @@ pub async fn get_state( /// `GET /state/lookup?key_commitment=` — resolves a Miden public-key /// commitment to the set of account IDs whose authorization set contains it. /// Authentication is by proof-of-possession against the queried commitment. +#[utoipa::path( + get, + path = "/state/lookup", + tag = "client", + params(LookupQuery), + responses( + (status = 200, description = "Accounts whose authorization set contains the commitment", body = LookupResponse), + (status = 401, description = "Authentication failed", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn lookup( State(state): State, AuthHeader(credentials): AuthHeader, @@ -253,29 +335,41 @@ pub async fn lookup( })) } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct PubkeyResponse { pub commitment: String, #[serde(skip_serializing_if = "Option::is_none")] pub pubkey: Option, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct ProposalsResponse { pub proposals: Vec, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct DeltaProposalResponse { pub delta: DeltaObject, pub commitment: String, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct PubkeyQuery { pub scheme: Option, } +/// Return the Guardian acknowledgement (ACK) public key / commitment +/// for the requested signature scheme (`falcon` default, or `ecdsa`). +#[utoipa::path( + get, + path = "/pubkey", + tag = "client", + params(PubkeyQuery), + responses( + (status = 200, description = "ACK public key / commitment", body = PubkeyResponse), + ) +)] pub async fn get_pubkey( State(state): State, Query(query): Query, @@ -293,6 +387,19 @@ pub async fn get_pubkey( (StatusCode::OK, Json(PubkeyResponse { commitment, pubkey })) } +/// Create a new multisig delta proposal for cosigners to sign. +#[utoipa::path( + post, + path = "/delta/proposal", + tag = "client", + request_body = DeltaProposalRequest, + responses( + (status = 200, description = "Proposal created", body = DeltaProposalResponse), + (status = 400, description = "Invalid proposal payload", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "Authentication failed", body = crate::openapi::ApiErrorResponse), + (status = 409, description = "Conflicting / too many pending proposals", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn push_delta_proposal( State(state): State, AuthHeader(credentials): AuthHeader, @@ -315,6 +422,17 @@ pub async fn push_delta_proposal( })) } +/// List all in-flight multisig proposals for an account. +#[utoipa::path( + get, + path = "/delta/proposal", + tag = "client", + params(ProposalQuery), + responses( + (status = 200, description = "Pending proposals", body = ProposalsResponse), + (status = 401, description = "Authentication failed", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn get_delta_proposals( State(state): State, AuthHeader(credentials): AuthHeader, @@ -334,6 +452,18 @@ pub async fn get_delta_proposals( })) } +/// Fetch a single multisig proposal by its commitment. +#[utoipa::path( + get, + path = "/delta/proposal/single", + tag = "client", + params(ProposalItemQuery), + responses( + (status = 200, description = "Proposal found", body = DeltaObject), + (status = 401, description = "Authentication failed", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "Proposal not found", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn get_delta_proposal( State(state): State, AuthHeader(credentials): AuthHeader, @@ -352,6 +482,21 @@ pub async fn get_delta_proposal( Ok(Json(response.proposal)) } +/// Add a cosigner signature to an existing multisig proposal. When the +/// signature threshold is reached the proposal is promoted to a delta. +#[utoipa::path( + put, + path = "/delta/proposal", + tag = "client", + request_body = SignProposalRequest, + responses( + (status = 200, description = "Signature accepted", body = DeltaObject), + (status = 400, description = "Invalid signature", body = crate::openapi::ApiErrorResponse), + (status = 401, description = "Authentication failed", body = crate::openapi::ApiErrorResponse), + (status = 404, description = "Proposal not found", body = crate::openapi::ApiErrorResponse), + (status = 409, description = "Proposal already signed by this signer", body = crate::openapi::ApiErrorResponse), + ) +)] pub async fn sign_delta_proposal( State(state): State, AuthHeader(credentials): AuthHeader, diff --git a/crates/server/src/bin/gen-openapi.rs b/crates/server/src/bin/gen-openapi.rs new file mode 100644 index 00000000..6edd998f --- /dev/null +++ b/crates/server/src/bin/gen-openapi.rs @@ -0,0 +1,30 @@ +//! Generate the Guardian OpenAPI specification (issue #241). +//! +//! Writes the JSON spec produced by [`server::openapi::openapi`] to the +//! path given as the first argument, or to stdout when no path is +//! given. Build with `--features evm` to include the EVM routes: +//! +//! ```sh +//! cargo run --features evm --bin gen-openapi -- docs/openapi.json +//! ``` +use std::io::Write; + +fn main() -> std::io::Result<()> { + let spec = server::openapi::openapi(); + let json = spec + .to_pretty_json() + .expect("OpenAPI spec must serialize to JSON"); + + match std::env::args().nth(1) { + Some(path) => { + let mut file = std::fs::File::create(&path)?; + // Trailing newline keeps the committed file POSIX-clean. + writeln!(file, "{json}")?; + eprintln!("Wrote OpenAPI spec to {path}"); + } + None => { + println!("{json}"); + } + } + Ok(()) +} diff --git a/crates/server/src/builder/handle.rs b/crates/server/src/builder/handle.rs index 40673552..b2123709 100644 --- a/crates/server/src/builder/handle.rs +++ b/crates/server/src/builder/handle.rs @@ -53,6 +53,12 @@ impl ServerHandle { "Hello, World!" } + // Issue #241: serve the auto-generated OpenAPI spec. Unauthenticated + // and read-only — it documents the contract, not data. + async fn openapi_json() -> axum::Json { + axum::Json(crate::openapi::openapi()) + } + let mut tasks = Vec::new(); // Start background jobs based on canonicalization config @@ -162,6 +168,7 @@ impl ServerHandle { let app = Router::new() .route("/", get(root)) + .route("/api-docs/openapi.json", get(openapi_json)) .route("/delta", post(push_delta)) .route("/delta", get(get_delta)) .route("/delta/since", get(get_delta_since)) diff --git a/crates/server/src/delta_object.rs b/crates/server/src/delta_object.rs index c23c7a71..978b6934 100644 --- a/crates/server/src/delta_object.rs +++ b/crates/server/src/delta_object.rs @@ -2,7 +2,7 @@ pub use guardian_shared::ProposalSignature; use serde::{Deserialize, Serialize}; /// Cosigner signature entry for delta proposals -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema)] pub struct CosignerSignature { pub signature: ProposalSignature, pub timestamp: String, @@ -10,7 +10,7 @@ pub struct CosignerSignature { } /// Delta status state machine -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema)] #[serde(tag = "status", rename_all = "snake_case")] pub enum DeltaStatus { Pending { @@ -121,13 +121,15 @@ impl Default for DeltaStatus { } /// Delta object -#[derive(Serialize, Clone, Debug, Default)] +#[derive(Serialize, Clone, Debug, Default, utoipa::ToSchema)] pub struct DeltaObject { pub account_id: String, pub nonce: u64, pub prev_commitment: String, #[serde(skip_serializing_if = "Option::is_none")] pub new_commitment: Option, + /// Opaque, schema-free JSON payload describing the state delta. + #[schema(value_type = Object)] pub delta_payload: serde_json::Value, pub ack_sig: String, pub ack_pubkey: String, diff --git a/crates/server/src/delta_summary/mod.rs b/crates/server/src/delta_summary/mod.rs index 28d7c481..fee0986b 100644 --- a/crates/server/src/delta_summary/mod.rs +++ b/crates/server/src/delta_summary/mod.rs @@ -29,7 +29,7 @@ pub use projection::{ /// Persisted activity metadata for a canonical delta. Stored as JSONB /// in the `deltas.metadata` column. `None` for EVM deltas and any /// historical row never reprocessed by [`build_metadata`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct DeltaMetadata { pub category: DashboardDeltaCategory, @@ -57,7 +57,7 @@ pub struct DeltaMetadata { /// Closed, stable enumeration of action categories. Adding a value is /// a wire-contract change. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum DashboardDeltaCategory { AssetTransfer, @@ -72,7 +72,7 @@ pub enum DashboardDeltaCategory { // is not yet implemented. Adding it before detection lands would ship // a wire value that's never emitted. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct AssetSummary { pub asset_id: String, pub kind: AssetKind, @@ -83,27 +83,27 @@ pub struct AssetSummary { pub amount: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum AssetKind { Fungible, NonFungible, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct CounterpartySummary { pub account_id: String, pub direction: CounterpartyDirection, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum CounterpartyDirection { Out, In, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)] pub struct NoteCounts { #[serde(default)] pub input: u32, @@ -114,7 +114,7 @@ pub struct NoteCounts { /// Operator-stated intent lifted from a matching proposal. Mirrors /// `ProposalMetadataPayload` in `crates/miden-multisig-client/src/payload.rs` /// field-for-field. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)] pub struct ProposalMetadata { /// One of the validated multisig proposal types (`add_signer`, /// `remove_signer`, `change_threshold`, `update_procedure_threshold`, @@ -162,7 +162,7 @@ pub struct ProposalMetadata { /// Detail-view types used by the per-delta endpoint; not built by the /// listing path. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DecodedNote { pub note_id: String, pub tag: NoteTag, @@ -173,7 +173,7 @@ pub struct DecodedNote { pub recipient: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum NoteTag { P2id, @@ -184,7 +184,7 @@ pub enum NoteTag { Custom, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DecodedAsset { pub asset_id: String, pub kind: AssetKind, @@ -192,7 +192,7 @@ pub struct DecodedAsset { pub amount: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum VaultChange { Fungible { @@ -206,7 +206,7 @@ pub enum VaultChange { }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct StorageChange { /// Human-readable slot name from /// `miden_protocol::account::StorageSlotName` (e.g. `"consumed_notes"`). @@ -228,13 +228,13 @@ pub struct StorageChange { pub after: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DecodeWarning { pub section: DecodeSection, pub reason: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum DecodeSection { TxSummary, diff --git a/crates/server/src/evm/proposal.rs b/crates/server/src/evm/proposal.rs index faf10cad..04d496be 100644 --- a/crates/server/src/evm/proposal.rs +++ b/crates/server/src/evm/proposal.rs @@ -6,14 +6,14 @@ use crate::metadata::network::normalize_evm_address; pub const EVM_PROPOSAL_KIND: &str = "evm"; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct EvmProposalSignature { pub signer: String, pub signature: String, pub signed_at: i64, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct EvmProposal { pub proposal_id: String, pub account_id: String, @@ -39,7 +39,7 @@ pub struct EvmProposalFilter { pub validator_address: Option, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct ExecutableEvmProposal { pub hash: String, pub payload: String, diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index f41420f5..9b0c03dc 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -22,6 +22,7 @@ pub mod evm; pub mod jobs; pub mod metadata; pub mod network; +pub mod openapi; pub mod services; pub mod state_object; pub mod storage; diff --git a/crates/server/src/metadata/auth/mod.rs b/crates/server/src/metadata/auth/mod.rs index 734d8469..4bcbdbc1 100644 --- a/crates/server/src/metadata/auth/mod.rs +++ b/crates/server/src/metadata/auth/mod.rs @@ -18,7 +18,7 @@ pub use credentials::{AuthHeader, Credentials, ExtractCredentials, MAX_TIMESTAMP /// Authentication and authorization handler /// Defines which signature scheme to use and handles verification /// Each variant contains auth-specific authorization data -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, utoipa::ToSchema)] pub enum Auth { /// Miden Falcon RPO signature scheme MidenFalconRpo { cosigner_commitments: Vec }, diff --git a/crates/server/src/metadata/network.rs b/crates/server/src/metadata/network.rs index e5f8e0eb..7d01ff03 100644 --- a/crates/server/src/metadata/network.rs +++ b/crates/server/src/metadata/network.rs @@ -1,7 +1,7 @@ use crate::api::grpc::guardian::{self, network_config}; use crate::network::NetworkType; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, utoipa::ToSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum NetworkConfig { Miden { @@ -14,7 +14,9 @@ pub enum NetworkConfig { }, } -#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[derive( + Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, utoipa::ToSchema, +)] #[serde(rename_all = "snake_case")] pub enum MidenNetworkType { Local, diff --git a/crates/server/src/openapi.rs b/crates/server/src/openapi.rs new file mode 100644 index 00000000..2e08c496 --- /dev/null +++ b/crates/server/src/openapi.rs @@ -0,0 +1,180 @@ +//! OpenAPI specification generation for the Guardian HTTP API. +//! +//! Issue #241. The spec is generated from `#[utoipa::path]` annotations +//! on the HTTP handlers and `#[derive(utoipa::ToSchema)]` on the wire +//! models. [`openapi`] returns the assembled document; it is served at +//! runtime from `GET /api-docs/openapi.json` (see `builder/handle.rs`) +//! and written to a committed file by the `gen-openapi` binary. +//! +//! Guardian exposes two HTTP surfaces, both documented here: the +//! **client** API (`tag = "client"`) consumed by SDKs/packages, and the +//! operator **dashboard** API (`tag = "dashboard"`). The feature-gated +//! **evm** surface is included when the `evm` feature is on. + +use serde::Serialize; +use utoipa::OpenApi; + +/// Wire shape of a Guardian error response body. Mirrors the envelope +/// produced by [`crate::error::GuardianError`]'s `IntoResponse` impl. +/// Documented as the body of every non-2xx response. Optional fields +/// are populated only for the error codes that carry them. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ApiErrorResponse { + /// Always `false` for error responses. + pub success: bool, + /// Stable, machine-readable error code (e.g. `account_not_found`). + pub code: String, + /// Human-readable error message. + pub error: String, + /// Seconds to wait before retrying. Present only for + /// `rate_limit_exceeded`. + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_secs: Option, + /// Lex-sorted permissions the operator lacks. Present only for + /// `GUARDIAN_INSUFFICIENT_OPERATOR_PERMISSION`. + #[serde(skip_serializing_if = "Option::is_none")] + pub missing_permissions: Option>, + /// `false` for permission denials and `GUARDIAN_ACCOUNT_PAUSED`. + #[serde(skip_serializing_if = "Option::is_none")] + pub retryable: Option, + /// RFC 3339 pause timestamp. Present only for + /// `GUARDIAN_ACCOUNT_PAUSED`. + #[serde(skip_serializing_if = "Option::is_none")] + pub paused_at: Option, + /// Pause reason. Present only for `GUARDIAN_ACCOUNT_PAUSED`. + #[serde(skip_serializing_if = "Option::is_none")] + pub paused_reason: Option, +} + +/// Always-on Guardian API surface: the client API and the operator +/// dashboard API. EVM routes are merged in separately by [`openapi`] +/// when the `evm` feature is enabled. +#[derive(OpenApi)] +#[openapi( + info( + title = "Guardian API", + description = "Guardian coordination service HTTP API. Covers the client-facing \ + contract consumed by SDKs/packages and the operator dashboard API.", + license(name = "AGPL-3.0", identifier = "AGPL-3.0"), + ), + paths( + // --- client API --- + crate::api::http::configure, + crate::api::http::push_delta, + crate::api::http::get_delta, + crate::api::http::get_delta_since, + crate::api::http::get_state, + crate::api::http::lookup, + crate::api::http::get_pubkey, + crate::api::http::push_delta_proposal, + crate::api::http::get_delta_proposals, + crate::api::http::get_delta_proposal, + crate::api::http::sign_delta_proposal, + // --- dashboard API --- + crate::api::dashboard::challenge_operator_login, + crate::api::dashboard::verify_operator_login, + crate::api::dashboard::logout_operator, + crate::api::dashboard::list_operator_accounts, + crate::api::dashboard::get_dashboard_info_handler, + crate::api::dashboard::get_dashboard_session_handler, + crate::api::dashboard::get_operator_account, + crate::api::dashboard::get_operator_account_snapshot, + crate::api::dashboard::pause_account_handler, + crate::api::dashboard::unpause_account_handler, + crate::api::dashboard_feeds::list_account_deltas_handler, + crate::api::dashboard_feeds::list_account_delta_detail_handler, + crate::api::dashboard_feeds::list_account_proposals_handler, + crate::api::dashboard_feeds::list_global_deltas_handler, + crate::api::dashboard_feeds::list_global_proposals_handler, + ), + components(schemas(ApiErrorResponse)), + tags( + (name = "client", description = "Client-facing API consumed by SDKs and packages."), + (name = "dashboard", description = "Operator dashboard API."), + (name = "evm", description = "EVM smart-account API (available when the `evm` feature is enabled)."), + ) +)] +pub struct ApiDoc; + +/// Feature-gated EVM API surface, merged into the base document by +/// [`openapi`] when the `evm` feature is enabled. +#[cfg(feature = "evm")] +#[derive(OpenApi)] +#[openapi(paths( + crate::api::evm::challenge_evm_session, + crate::api::evm::verify_evm_session, + crate::api::evm::logout_evm_session, + crate::api::evm::register_evm_account, + crate::api::evm::create_evm_proposal, + crate::api::evm::list_evm_proposals, + crate::api::evm::get_evm_proposal, + crate::api::evm::approve_evm_proposal, + crate::api::evm::get_executable_evm_proposal, + crate::api::evm::cancel_evm_proposal, +))] +struct EvmApiDoc; + +/// Build the complete OpenAPI document for the running server build. +/// The version is taken from the crate version at compile time. EVM +/// paths/schemas are merged in only when the `evm` feature is active so +/// the spec always reflects the routes actually mounted. +pub fn openapi() -> utoipa::openapi::OpenApi { + let mut doc = ApiDoc::openapi(); + doc.info.version = env!("CARGO_PKG_VERSION").to_string(); + + #[cfg(feature = "evm")] + doc.merge(EvmApiDoc::openapi()); + + doc +} + +#[cfg(all(test, not(any(feature = "integration", feature = "e2e"))))] +mod tests { + use super::*; + + #[test] + fn openapi_spec_builds_and_serializes() { + let doc = openapi(); + // Serializes to valid JSON. + let json = serde_json::to_value(&doc).expect("spec serializes to JSON"); + assert_eq!(json["openapi"].as_str().unwrap_or(""), "3.1.0"); + + // A representative sample of every surface is present. + let paths = json["paths"].as_object().expect("paths object"); + assert!(paths.contains_key("/configure"), "client API path missing"); + assert!(paths.contains_key("/delta"), "client API path missing"); + assert!( + paths.contains_key("/dashboard/accounts"), + "dashboard API path missing" + ); + assert!( + paths.contains_key("/dashboard/accounts/{account_id}/deltas/{nonce}"), + "dashboard feed path missing" + ); + + // Core wire models are registered as components. + let schemas = json["components"]["schemas"] + .as_object() + .expect("schemas object"); + assert!( + schemas.contains_key("DeltaObject"), + "DeltaObject schema missing" + ); + assert!( + schemas.contains_key("StateObject"), + "StateObject schema missing" + ); + assert!( + schemas.contains_key("ApiErrorResponse"), + "error schema missing" + ); + } + + #[cfg(feature = "evm")] + #[test] + fn openapi_spec_includes_evm_paths_when_feature_enabled() { + let json = serde_json::to_value(openapi()).unwrap(); + let paths = json["paths"].as_object().unwrap(); + assert!(paths.contains_key("/evm/proposals"), "evm path missing"); + } +} diff --git a/crates/server/src/services/account_status.rs b/crates/server/src/services/account_status.rs index 24b16b38..1f228ca1 100644 --- a/crates/server/src/services/account_status.rs +++ b/crates/server/src/services/account_status.rs @@ -15,7 +15,7 @@ use crate::error::{GuardianError, Result}; use crate::metadata::AccountMetadata; use crate::state::AppState; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "lowercase")] pub enum AccountStatus { Active, diff --git a/crates/server/src/services/dashboard_account_delta_detail.rs b/crates/server/src/services/dashboard_account_delta_detail.rs index afa9a3ec..a62090db 100644 --- a/crates/server/src/services/dashboard_account_delta_detail.rs +++ b/crates/server/src/services/dashboard_account_delta_detail.rs @@ -23,7 +23,7 @@ use crate::state::AppState; /// metadata column. `note_counts`, `asset`, and `counterparty` are /// intentionally omitted — they are derivable from the per-section /// arrays below. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardDeltaDetail { pub account_id: String, pub nonce: u64, diff --git a/crates/server/src/services/dashboard_account_deltas.rs b/crates/server/src/services/dashboard_account_deltas.rs index ce01b168..971ef164 100644 --- a/crates/server/src/services/dashboard_account_deltas.rs +++ b/crates/server/src/services/dashboard_account_deltas.rs @@ -27,7 +27,7 @@ use crate::storage::AccountDeltaCursor; /// Lifecycle status surfaced on the per-account delta feed endpoint. /// `pending`-status records live in `delta_proposals` and are /// surfaced via the proposal queue endpoint instead. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum DashboardDeltaStatus { Candidate, @@ -39,7 +39,7 @@ pub enum DashboardDeltaStatus { /// `account_id` is omitted on per-account responses (the path scopes /// it). The global delta feed (Phase 8) wraps this struct with /// `account_id` so a single shape is shared. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardDeltaEntry { pub nonce: u64, pub status: DashboardDeltaStatus, diff --git a/crates/server/src/services/dashboard_account_proposals.rs b/crates/server/src/services/dashboard_account_proposals.rs index fac1bdd4..a35fe3cf 100644 --- a/crates/server/src/services/dashboard_account_proposals.rs +++ b/crates/server/src/services/dashboard_account_proposals.rs @@ -26,7 +26,7 @@ use crate::storage::AccountProposalCursor; /// One proposal entry in the wire shape per `data-model.md`. /// `account_id` is omitted on per-account responses; the global /// proposal feed (Phase 9) wraps this with `account_id`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardProposalEntry { /// Cryptographic identifier cosigners are signing. Per-account /// stable identifier. diff --git a/crates/server/src/services/dashboard_account_snapshot.rs b/crates/server/src/services/dashboard_account_snapshot.rs index 822d2528..ec40efe2 100644 --- a/crates/server/src/services/dashboard_account_snapshot.rs +++ b/crates/server/src/services/dashboard_account_snapshot.rs @@ -25,7 +25,7 @@ use crate::state::AppState; /// One fungible asset entry in the vault snapshot. `amount` is a string /// to keep `u64`-precision values safe across JS (`Number.MAX_SAFE_INTEGER` /// is 2^53 − 1). Decimal handling is a dashboard-client concern. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardVaultFungibleEntry { pub faucet_id: String, pub amount: String, @@ -33,19 +33,19 @@ pub struct DashboardVaultFungibleEntry { /// One non-fungible asset entry. `vault_key` is the canonical Miden /// identifier for the asset within the vault. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardVaultNonFungibleEntry { pub faucet_id: String, pub vault_key: String, } -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, utoipa::ToSchema)] pub struct DashboardVaultSnapshot { pub fungible: Vec, pub non_fungible: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardAccountSnapshot { /// Commitment of the state the snapshot was decoded from. Equals /// `DashboardAccountDetail::current_commitment` for the same diff --git a/crates/server/src/services/dashboard_accounts.rs b/crates/server/src/services/dashboard_accounts.rs index 15620989..c24193c0 100644 --- a/crates/server/src/services/dashboard_accounts.rs +++ b/crates/server/src/services/dashboard_accounts.rs @@ -8,14 +8,14 @@ use crate::services::dashboard_pagination::PagedResult; use crate::state::AppState; use crate::state_object::StateObject; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum DashboardAccountStateStatus { Available, Unavailable, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)] pub struct DashboardAccountSummary { pub account_id: String, /// Bech32m encoding of the Miden `AccountId` using the network's @@ -37,7 +37,7 @@ pub struct DashboardAccountSummary { pub paused_reason: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)] pub struct DashboardAccountDetail { pub account_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/server/src/services/dashboard_global_deltas.rs b/crates/server/src/services/dashboard_global_deltas.rs index 0ca99e18..1ce6cc5f 100644 --- a/crates/server/src/services/dashboard_global_deltas.rs +++ b/crates/server/src/services/dashboard_global_deltas.rs @@ -32,7 +32,7 @@ use crate::storage::{DeltaStatusKind, GlobalDeltaCursor}; /// Carries every field of a per-account [`DashboardDeltaEntry`] plus /// `account_id` so the dashboard can group / link without a second /// request. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardGlobalDeltaEntry { pub account_id: String, pub nonce: u64, diff --git a/crates/server/src/services/dashboard_global_proposals.rs b/crates/server/src/services/dashboard_global_proposals.rs index 8415d6d8..53b32c21 100644 --- a/crates/server/src/services/dashboard_global_proposals.rs +++ b/crates/server/src/services/dashboard_global_proposals.rs @@ -48,7 +48,7 @@ use crate::storage::GlobalProposalCursor; /// One entry in the global proposal feed wire shape per /// `data-model.md`. Includes every field of the per-account /// proposal entry plus `account_id`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardGlobalProposalEntry { pub account_id: String, pub commitment: String, diff --git a/crates/server/src/services/dashboard_info.rs b/crates/server/src/services/dashboard_info.rs index 4f06749d..27c94efa 100644 --- a/crates/server/src/services/dashboard_info.rs +++ b/crates/server/src/services/dashboard_info.rs @@ -42,14 +42,14 @@ pub const AGG_IN_FLIGHT_PROPOSAL_COUNT: &str = "in_flight_proposal_count"; pub const AGG_LATEST_ACTIVITY: &str = "latest_activity"; pub const AGG_ACCOUNTS_BY_AUTH_METHOD: &str = "accounts_by_auth_method"; -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum DashboardServiceStatus { Healthy, Degraded, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardDeltaStatusCounts { pub candidate: u64, pub canonical: u64, @@ -59,7 +59,7 @@ pub struct DashboardDeltaStatusCounts { /// Build identity for the running `guardian-server` binary. Values are /// stable for the lifetime of the process; surfaced so operators can /// confirm which version/SHA is responding without reading logs. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardBuildInfo { /// `CARGO_PKG_VERSION` from `guardian-server`. pub version: &'static str, @@ -76,7 +76,7 @@ pub struct DashboardBuildInfo { /// Per-account-method canonicalization fan-in configuration. `None` /// means the server is running in optimistic mode (deltas are written /// directly as canonical and never enter the candidate state). -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardCanonicalizationConfig { pub check_interval_seconds: u64, pub max_retries: u32, @@ -86,7 +86,7 @@ pub struct DashboardCanonicalizationConfig { /// Backend configuration snapshot. Stable for the lifetime of the /// process; lets operators distinguish a filesystem dev box from a /// postgres-backed prod replica without inspecting environment. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardBackendInfo { /// `"filesystem"` or `"postgres"` from the cargo feature flag. pub storage: &'static str, @@ -99,7 +99,7 @@ pub struct DashboardBackendInfo { pub canonicalization: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct DashboardInfoResponse { pub service_status: DashboardServiceStatus, pub environment: String, diff --git a/crates/server/src/services/dashboard_pagination.rs b/crates/server/src/services/dashboard_pagination.rs index 354247de..c82eadfb 100644 --- a/crates/server/src/services/dashboard_pagination.rs +++ b/crates/server/src/services/dashboard_pagination.rs @@ -53,7 +53,7 @@ pub const MAX_LIMIT: u32 = 500; /// Standard cursor-pagination envelope returned by every paginated /// endpoint. `next_cursor` is `None` at end of list. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct PagedResult { pub items: Vec, pub next_cursor: Option, diff --git a/crates/server/src/services/pause_account.rs b/crates/server/src/services/pause_account.rs index f004ae8c..48fac3c5 100644 --- a/crates/server/src/services/pause_account.rs +++ b/crates/server/src/services/pause_account.rs @@ -19,7 +19,7 @@ use crate::state::AppState; pub const MAX_REASON_LEN: usize = 512; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct PauseResponse { pub account_id: String, pub before_state: AccountStatus, diff --git a/crates/server/src/services/unpause_account.rs b/crates/server/src/services/unpause_account.rs index a80201f6..399cf293 100644 --- a/crates/server/src/services/unpause_account.rs +++ b/crates/server/src/services/unpause_account.rs @@ -13,7 +13,7 @@ use crate::services::account_status::{AccountStatus, PauseTransition}; use crate::services::pause_account::validate_reason; use crate::state::AppState; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct UnpauseResponse { pub account_id: String, pub before_state: AccountStatus, diff --git a/crates/server/src/state_object.rs b/crates/server/src/state_object.rs index 4261e093..21b00d56 100644 --- a/crates/server/src/state_object.rs +++ b/crates/server/src/state_object.rs @@ -1,9 +1,11 @@ use serde::{Deserialize, Serialize}; /// Account state object -#[derive(Serialize, Deserialize, Clone, Debug, Default)] +#[derive(Serialize, Deserialize, Clone, Debug, Default, utoipa::ToSchema)] pub struct StateObject { pub account_id: String, + /// Opaque, schema-free JSON blob describing the account state. + #[schema(value_type = Object)] pub state_json: serde_json::Value, pub commitment: String, pub created_at: String, diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 7636a60a..95cb00d9 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -17,6 +17,7 @@ base64 = { workspace = true } rand = "0.8" hex = { workspace = true } prost = { workspace = true } +utoipa = { workspace = true } [dev-dependencies] miden-standards = { version = "=0.14.5" } diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index f9922611..fee1bc63 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -17,7 +17,7 @@ pub mod lookup_auth_message; use crate::hex::FromHex; /// Supported signature schemes -#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum SignatureScheme { Falcon, @@ -133,7 +133,7 @@ fn parse_ecdsa_public_key_hex( } /// Signature type for delta proposals -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema)] #[serde(tag = "scheme", rename_all = "snake_case")] pub enum ProposalSignature { Falcon { diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md new file mode 100644 index 00000000..f3c56aac --- /dev/null +++ b/docs/OPENAPI.md @@ -0,0 +1,41 @@ +# OpenAPI specification + +Guardian's HTTP API is described by an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) +specification generated directly from the server source with +[`utoipa`](https://docs.rs/utoipa). Because the spec is derived from the +same `#[utoipa::path]` annotations and `#[derive(ToSchema)]` models the +handlers use, it cannot drift from the implementation. + +It covers both HTTP surfaces: + +- the **client** API (tag `client`) consumed by the SDKs and packages, and +- the operator **dashboard** API (tag `dashboard`). + +The EVM smart-account API (tag `evm`) is included when the `evm` Cargo +feature is enabled. + +## Where it lives + +- **Checked-in spec:** [`docs/openapi.json`](./openapi.json) — generated + with the `evm` feature so it documents every route. +- **Served at runtime:** `GET /api-docs/openapi.json` on the HTTP server + returns the spec for the routes the running binary actually mounts + (EVM routes appear only when the server is built with `--features evm`). + +Point any OpenAPI tooling — Swagger UI, ReDoc, or a client-SDK +generator — at either source. + +## Regenerating the checked-in file + +Run the `gen-openapi` binary and write to `docs/openapi.json`. Build with +`--features evm` so the EVM routes are included: + +```sh +cargo run --features evm --bin gen-openapi -- docs/openapi.json +``` + +With no path argument the spec is printed to stdout instead. + +Regenerate and commit `docs/openapi.json` whenever you add or change an +HTTP handler, its request/response types, or a model that appears on the +wire — the same way the proto contract is kept in sync. diff --git a/docs/README.md b/docs/README.md index 88487ec4..eca6c142 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,10 @@ You are an SDK consumer or integrator. client: account creation, proposal lifecycle, offline signing. 4. [`spec/api.md`](../spec/api.md) — wire-level API contract (auth headers, request signing, data shapes). -5. [Troubleshooting](./TROUBLESHOOTING.md) — error code reference for +5. [OpenAPI specification](./OPENAPI.md) — machine-readable OpenAPI 3.1 + spec ([`docs/openapi.json`](./openapi.json)) for Swagger UI / ReDoc / + client generators. +6. [Troubleshooting](./TROUBLESHOOTING.md) — error code reference for anything your SDK surfaces. ### I want to *run* Guardian (deploy and operate) @@ -91,6 +94,7 @@ You are a contributor. **Reference** - [Configuration (env vars)](./CONFIGURATION.md) +- [OpenAPI specification](./OPENAPI.md) — HTTP API spec ([`openapi.json`](./openapi.json)) - [`spec/`](../spec/index.md) — protocol specification - [`infra/README.md`](../infra/README.md) — Terraform variables diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 00000000..41450e83 --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,4852 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Guardian API", + "description": "Guardian coordination service HTTP API. Covers the client-facing contract consumed by SDKs/packages and the operator dashboard API.", + "license": { + "name": "AGPL-3.0", + "identifier": "AGPL-3.0" + }, + "version": "0.1.0" + }, + "paths": { + "/auth/challenge": { + "get": { + "tags": [ + "dashboard" + ], + "summary": "Issue a login challenge for an operator commitment. The operator\nsigns the returned `signing_digest` and submits it to `/auth/verify`.", + "operationId": "challenge_operator_login", + "parameters": [ + { + "name": "commitment", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Challenge issued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperatorChallengeResponse" + } + } + } + }, + "400": { + "description": "Invalid commitment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/auth/logout": { + "post": { + "tags": [ + "dashboard" + ], + "summary": "Invalidate the current operator session and clear the session cookie.", + "operationId": "logout_operator", + "responses": { + "200": { + "description": "Session invalidated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutOperatorResponse" + } + } + } + } + } + } + }, + "/auth/verify": { + "post": { + "tags": [ + "dashboard" + ], + "summary": "Verify a signed login challenge and establish an operator session\n(sets a session cookie on success).", + "operationId": "verify_operator_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyOperatorRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Session established", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyOperatorResponse" + } + } + } + }, + "401": { + "description": "Challenge verification failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/configure": { + "post": { + "tags": [ + "client" + ], + "summary": "Configure (register) an account with its authorization set and\ninitial state. Requires a signed `X-Guardian-*` auth header.", + "operationId": "configure", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigureRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Account configured", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigureResponse" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigureResponse" + } + } + } + } + } + } + }, + "/dashboard/accounts": { + "get": { + "tags": [ + "dashboard" + ], + "summary": "Paginated list of accounts visible to the operator. Requires the\n`dashboard:read` permission and a valid operator session.", + "operationId": "list_operator_accounts", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "paused", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Account page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedResult_DashboardAccountSummary" + } + } + } + }, + "400": { + "description": "Invalid limit or cursor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "403": { + "description": "Missing dashboard:read permission", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/dashboard/accounts/{account_id}": { + "get": { + "tags": [ + "dashboard" + ], + "summary": "Fetch the detail view for one account.", + "operationId": "get_operator_account", + "parameters": [ + { + "name": "account_id", + "in": "path", + "description": "Account identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Account detail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardAccountDetail" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "403": { + "description": "Missing dashboard:read permission", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "503": { + "description": "Account state unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/dashboard/accounts/{account_id}/deltas": { + "get": { + "tags": [ + "dashboard" + ], + "summary": "`GET /dashboard/accounts/{account_id}/deltas`. Returns the\nper-account delta feed paginated newest-first by `nonce DESC`,\nsurfacing only `candidate` / `canonical` / `discarded` statuses\n(pending lives on the proposal queue endpoint).", + "operationId": "list_account_deltas_handler", + "parameters": [ + { + "name": "account_id", + "in": "path", + "description": "Account identifier", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Per-account delta feed page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedResult_DashboardDeltaEntry" + } + } + } + }, + "400": { + "description": "Invalid limit or cursor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Account not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/dashboard/accounts/{account_id}/deltas/{nonce}": { + "get": { + "tags": [ + "dashboard" + ], + "summary": "`GET /dashboard/accounts/{account_id}/deltas/{nonce}`. Returns the\nfull detail projection of one canonical delta. `{nonce}` MUST be a\ncanonical base-10 `u64`; leading zeros (except `\"0\"`), negatives,\nhex, and other non-decimal inputs are rejected with\n[`GuardianError::InvalidInput`]. Unknown account or unknown nonce\nboth map to `DeltaNotFound` so the wire body is field-level identical.", + "operationId": "list_account_delta_detail_handler", + "parameters": [ + { + "name": "account_id", + "in": "path", + "description": "Account identifier", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "nonce", + "in": "path", + "description": "Canonical base-10 delta nonce", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "include", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Decoded delta detail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardDeltaDetail" + } + } + } + }, + "400": { + "description": "Malformed nonce", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Delta not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/dashboard/accounts/{account_id}/pause": { + "post": { + "tags": [ + "dashboard" + ], + "summary": "Pause an account (blocks mutating actions). Requires the\n`accounts:pause` permission.", + "operationId": "pause_account_handler", + "parameters": [ + { + "name": "account_id", + "in": "path", + "description": "Account identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseAccountRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Account paused", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseResponse" + } + } + } + }, + "400": { + "description": "Invalid or missing reason", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "403": { + "description": "Missing accounts:pause permission", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Account not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/dashboard/accounts/{account_id}/proposals": { + "get": { + "tags": [ + "dashboard" + ], + "summary": "`GET /dashboard/accounts/{account_id}/proposals`. Returns the\nin-flight multisig proposal queue for one account, paginated\nnewest-first by `(nonce DESC, commitment DESC)`. Single-key Miden\nand EVM accounts always return an empty page per FR-017.", + "operationId": "list_account_proposals_handler", + "parameters": [ + { + "name": "account_id", + "in": "path", + "description": "Account identifier", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Per-account in-flight proposal queue page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedResult_DashboardProposalEntry" + } + } + } + }, + "400": { + "description": "Invalid limit or cursor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Account not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/dashboard/accounts/{account_id}/snapshot": { + "get": { + "tags": [ + "dashboard" + ], + "summary": "Decode and return the current vault snapshot for a Miden account.", + "operationId": "get_operator_account_snapshot", + "parameters": [ + { + "name": "account_id", + "in": "path", + "description": "Account identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Account snapshot", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardAccountSnapshot" + } + } + } + }, + "400": { + "description": "Unsupported for this network (e.g. EVM)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "403": { + "description": "Missing dashboard:read permission", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/dashboard/accounts/{account_id}/unpause": { + "post": { + "tags": [ + "dashboard" + ], + "summary": "Unpause an account (idempotent). Requires the `accounts:pause`\npermission. Accepts an optional reason in the body.", + "operationId": "unpause_account_handler", + "parameters": [ + { + "name": "account_id", + "in": "path", + "description": "Account identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnpauseAccountRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Account unpaused", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnpauseResponse" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "403": { + "description": "Missing accounts:pause permission", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Account not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/dashboard/deltas": { + "get": { + "tags": [ + "dashboard" + ], + "summary": "`GET /dashboard/deltas`. Cross-account delta feed paginated\nnewest-first by `status_timestamp DESC`. Optional comma-separated\n`status` filter restricts to a subset of `{candidate, canonical,\ndiscarded}`. Pending entries live on the proposal feed.", + "description": "Spec reference: `005-operator-dashboard-metrics` US6, FR-031..FR-035.", + "operationId": "list_global_deltas_handler", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cross-account delta feed page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedResult_DashboardGlobalDeltaEntry" + } + } + } + }, + "400": { + "description": "Invalid limit, cursor, or status filter", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "503": { + "description": "Aggregate degraded (filesystem threshold exceeded)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/dashboard/info": { + "get": { + "tags": [ + "dashboard" + ], + "summary": "`GET /dashboard/info` — point-in-time inventory and lifecycle\nsummary per feature `005-operator-dashboard-metrics` US2.", + "operationId": "get_dashboard_info_handler", + "responses": { + "200": { + "description": "Inventory and lifecycle summary", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardInfoResponse" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "403": { + "description": "Missing dashboard:read permission", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/dashboard/proposals": { + "get": { + "tags": [ + "dashboard" + ], + "summary": "`GET /dashboard/proposals`. Cross-account in-flight proposal feed\npaginated newest-first by `originating_timestamp DESC`. Takes no\n`status` filter — every entry is in-flight by definition. EVM\naccounts do not appear in v1 per FR-017.", + "description": "Spec reference: `005-operator-dashboard-metrics` US7, FR-035..FR-037.", + "operationId": "list_global_proposals_handler", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cross-account in-flight proposal feed page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedResult_DashboardGlobalProposalEntry" + } + } + } + }, + "400": { + "description": "Invalid limit or cursor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "503": { + "description": "Aggregate degraded (filesystem threshold exceeded)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/dashboard/session": { + "get": { + "tags": [ + "dashboard" + ], + "summary": "Introspect the current operator session: operator id and the\nlex-ordered set of effective permissions.", + "operationId": "get_dashboard_session_handler", + "responses": { + "200": { + "description": "Session info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionInfoResponse" + } + } + } + }, + "401": { + "description": "No operator session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/delta": { + "get": { + "tags": [ + "client" + ], + "summary": "Fetch the delta for an account at a specific nonce.", + "operationId": "get_delta", + "parameters": [ + { + "name": "account_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "nonce", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Delta found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeltaObject" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Delta not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "client" + ], + "summary": "Push a signed state delta for a single-key account. The request\nbody is the JSON-encoded [`DeltaObject`] to commit.", + "operationId": "push_delta", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeltaObject" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Delta accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeltaObject" + } + } + } + }, + "400": { + "description": "Invalid delta payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "409": { + "description": "Conflicting pending delta/proposal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/delta/proposal": { + "get": { + "tags": [ + "client" + ], + "summary": "List all in-flight multisig proposals for an account.", + "operationId": "get_delta_proposals", + "parameters": [ + { + "name": "account_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Pending proposals", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProposalsResponse" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "client" + ], + "summary": "Add a cosigner signature to an existing multisig proposal. When the\nsignature threshold is reached the proposal is promoted to a delta.", + "operationId": "sign_delta_proposal", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignProposalRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Signature accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeltaObject" + } + } + } + }, + "400": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Proposal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "409": { + "description": "Proposal already signed by this signer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "client" + ], + "summary": "Create a new multisig delta proposal for cosigners to sign.", + "operationId": "push_delta_proposal", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeltaProposalRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Proposal created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeltaProposalResponse" + } + } + } + }, + "400": { + "description": "Invalid proposal payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "409": { + "description": "Conflicting / too many pending proposals", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/delta/proposal/single": { + "get": { + "tags": [ + "client" + ], + "summary": "Fetch a single multisig proposal by its commitment.", + "operationId": "get_delta_proposal", + "parameters": [ + { + "name": "account_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "commitment", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Proposal found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeltaObject" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Proposal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/delta/since": { + "get": { + "tags": [ + "client" + ], + "summary": "Fetch the merged delta accumulating all changes for an account\nsince (and excluding) the given nonce.", + "operationId": "get_delta_since", + "parameters": [ + { + "name": "account_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "nonce", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Merged delta", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeltaObject" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "No deltas found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/evm/accounts": { + "post": { + "tags": [ + "evm" + ], + "summary": "Register an EVM smart-account with Guardian (requires an EVM session).", + "operationId": "register_evm_account", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterAccountRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Account registered", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterAccountResponse" + } + } + } + }, + "400": { + "description": "Invalid network config", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Missing EVM session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/evm/auth/challenge": { + "get": { + "tags": [ + "evm" + ], + "summary": "Issue an EIP-712 session challenge for an EVM wallet address.", + "operationId": "challenge_evm_session", + "parameters": [ + { + "name": "address", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Challenge issued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChallengeResponse" + } + } + } + }, + "400": { + "description": "Invalid address", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/evm/auth/logout": { + "post": { + "tags": [ + "evm" + ], + "summary": "Invalidate the current EVM session and clear the session cookie.", + "operationId": "logout_evm_session", + "responses": { + "200": { + "description": "Session invalidated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponse" + } + } + } + } + } + } + }, + "/evm/auth/verify": { + "post": { + "tags": [ + "evm" + ], + "summary": "Verify a signed EVM session challenge and establish a session\n(sets a session cookie on success).", + "operationId": "verify_evm_session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifySessionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Session established", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifySessionResponse" + } + } + } + }, + "401": { + "description": "Challenge verification failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/evm/proposals": { + "get": { + "tags": [ + "evm" + ], + "summary": "List EVM proposals for an account (requires an EVM session).", + "operationId": "list_evm_proposals", + "parameters": [ + { + "name": "account_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Proposals", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListProposalsResponse" + } + } + } + }, + "401": { + "description": "Missing EVM session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "evm" + ], + "summary": "Create a new EVM multisig proposal (requires an EVM session).", + "operationId": "create_evm_proposal", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProposalRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Proposal created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvmProposal" + } + } + } + }, + "400": { + "description": "Invalid proposal input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Missing EVM session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/evm/proposals/{proposal_id}": { + "get": { + "tags": [ + "evm" + ], + "summary": "Fetch a single EVM proposal by id (requires an EVM session).", + "operationId": "get_evm_proposal", + "parameters": [ + { + "name": "proposal_id", + "in": "path", + "description": "Proposal identifier", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "account_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Proposal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvmProposal" + } + } + } + }, + "401": { + "description": "Missing EVM session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Proposal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/evm/proposals/{proposal_id}/approve": { + "post": { + "tags": [ + "evm" + ], + "summary": "Add an approval signature to an EVM proposal (requires an EVM session).", + "operationId": "approve_evm_proposal", + "parameters": [ + { + "name": "proposal_id", + "in": "path", + "description": "Proposal identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApproveProposalRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Approval recorded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvmProposal" + } + } + } + }, + "400": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Missing EVM session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Proposal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/evm/proposals/{proposal_id}/cancel": { + "post": { + "tags": [ + "evm" + ], + "summary": "Cancel an EVM proposal (requires an EVM session).", + "operationId": "cancel_evm_proposal", + "parameters": [ + { + "name": "proposal_id", + "in": "path", + "description": "Proposal identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelProposalRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Proposal cancelled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelProposalResponse" + } + } + } + }, + "401": { + "description": "Missing EVM session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Proposal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/evm/proposals/{proposal_id}/executable": { + "get": { + "tags": [ + "evm" + ], + "summary": "Fetch the executable (threshold-met) form of an EVM proposal,\nready for on-chain submission (requires an EVM session).", + "operationId": "get_executable_evm_proposal", + "parameters": [ + { + "name": "proposal_id", + "in": "path", + "description": "Proposal identifier", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "account_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Executable proposal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutableEvmProposal" + } + } + } + }, + "400": { + "description": "Proposal not yet executable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Missing EVM session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Proposal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/pubkey": { + "get": { + "tags": [ + "client" + ], + "summary": "Return the Guardian acknowledgement (ACK) public key / commitment\nfor the requested signature scheme (`falcon` default, or `ecdsa`).", + "operationId": "get_pubkey", + "parameters": [ + { + "name": "scheme", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "ACK public key / commitment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PubkeyResponse" + } + } + } + } + } + } + }, + "/state": { + "get": { + "tags": [ + "client" + ], + "summary": "Fetch the latest canonical state object for an account.", + "operationId": "get_state", + "parameters": [ + { + "name": "account_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Current account state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StateObject" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "State not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/state/lookup": { + "get": { + "tags": [ + "client" + ], + "summary": "`GET /state/lookup?key_commitment=` — resolves a Miden public-key\ncommitment to the set of account IDs whose authorization set contains it.\nAuthentication is by proof-of-possession against the queried commitment.", + "operationId": "lookup", + "parameters": [ + { + "name": "key_commitment", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Accounts whose authorization set contains the commitment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LookupResponse" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AccountStatus": { + "type": "string", + "enum": [ + "active", + "paused" + ] + }, + "ApiErrorResponse": { + "type": "object", + "description": "Wire shape of a Guardian error response body. Mirrors the envelope\nproduced by [`crate::error::GuardianError`]'s `IntoResponse` impl.\nDocumented as the body of every non-2xx response. Optional fields\nare populated only for the error codes that carry them.", + "required": [ + "success", + "code", + "error" + ], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code (e.g. `account_not_found`)." + }, + "error": { + "type": "string", + "description": "Human-readable error message." + }, + "missing_permissions": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Lex-sorted permissions the operator lacks. Present only for\n`GUARDIAN_INSUFFICIENT_OPERATOR_PERMISSION`." + }, + "paused_at": { + "type": [ + "string", + "null" + ], + "description": "RFC 3339 pause timestamp. Present only for\n`GUARDIAN_ACCOUNT_PAUSED`." + }, + "paused_reason": { + "type": [ + "string", + "null" + ], + "description": "Pause reason. Present only for `GUARDIAN_ACCOUNT_PAUSED`." + }, + "retry_after_secs": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Seconds to wait before retrying. Present only for\n`rate_limit_exceeded`.", + "minimum": 0 + }, + "retryable": { + "type": [ + "boolean", + "null" + ], + "description": "`false` for permission denials and `GUARDIAN_ACCOUNT_PAUSED`." + }, + "success": { + "type": "boolean", + "description": "Always `false` for error responses." + } + } + }, + "ApproveProposalRequest": { + "type": "object", + "required": [ + "account_id", + "signature" + ], + "properties": { + "account_id": { + "type": "string" + }, + "signature": { + "type": "string" + } + } + }, + "AssetKind": { + "type": "string", + "enum": [ + "fungible", + "non_fungible" + ] + }, + "AssetSummary": { + "type": "object", + "required": [ + "asset_id", + "kind" + ], + "properties": { + "amount": { + "type": [ + "string", + "null" + ], + "description": "Signed decimal magnitude (e.g., `\"+100\"`, `\"-50\"`) for fungible\nholdings. Absent for non-fungible holdings where the detail\nview uses `added` / `removed` lists instead." + }, + "asset_id": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/AssetKind" + } + } + }, + "Auth": { + "oneOf": [ + { + "type": "object", + "description": "Miden Falcon RPO signature scheme", + "required": [ + "MidenFalconRpo" + ], + "properties": { + "MidenFalconRpo": { + "type": "object", + "description": "Miden Falcon RPO signature scheme", + "required": [ + "cosigner_commitments" + ], + "properties": { + "cosigner_commitments": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "type": "object", + "description": "Miden ECDSA secp256k1 signature scheme", + "required": [ + "MidenEcdsa" + ], + "properties": { + "MidenEcdsa": { + "type": "object", + "description": "Miden ECDSA secp256k1 signature scheme", + "required": [ + "cosigner_commitments" + ], + "properties": { + "cosigner_commitments": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "type": "object", + "description": "EVM ECDSA account signer snapshot", + "required": [ + "EvmEcdsa" + ], + "properties": { + "EvmEcdsa": { + "type": "object", + "description": "EVM ECDSA account signer snapshot", + "required": [ + "signers" + ], + "properties": { + "signers": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + ], + "description": "Authentication and authorization handler\nDefines which signature scheme to use and handles verification\nEach variant contains auth-specific authorization data" + }, + "CancelProposalRequest": { + "type": "object", + "required": [ + "account_id" + ], + "properties": { + "account_id": { + "type": "string" + } + } + }, + "CancelProposalResponse": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean" + } + } + }, + "ChallengeResponse": { + "type": "object", + "required": [ + "address", + "nonce", + "issued_at", + "expires_at", + "typed_data" + ], + "properties": { + "address": { + "type": "string" + }, + "expires_at": { + "type": "integer", + "format": "int64" + }, + "issued_at": { + "type": "integer", + "format": "int64" + }, + "nonce": { + "type": "string" + }, + "typed_data": { + "type": "object", + "description": "EIP-712 typed-data payload the wallet signs to establish a session." + } + } + }, + "ConfigureRequest": { + "type": "object", + "required": [ + "account_id", + "auth", + "initial_state" + ], + "properties": { + "account_id": { + "type": "string" + }, + "auth": { + "$ref": "#/components/schemas/Auth" + }, + "initial_state": { + "type": "object", + "description": "Opaque, schema-free JSON blob describing the initial account state." + }, + "network_config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NetworkConfig" + } + ] + } + } + }, + "ConfigureResponse": { + "type": "object", + "required": [ + "success", + "message" + ], + "properties": { + "ack_commitment": { + "type": [ + "string", + "null" + ] + }, + "ack_pubkey": { + "type": [ + "string", + "null" + ] + }, + "code": { + "type": [ + "string", + "null" + ] + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "CosignerSignature": { + "type": "object", + "description": "Cosigner signature entry for delta proposals", + "required": [ + "signature", + "timestamp", + "signer_id" + ], + "properties": { + "signature": { + "$ref": "#/components/schemas/ProposalSignature" + }, + "signer_id": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + } + }, + "CounterpartyDirection": { + "type": "string", + "enum": [ + "out", + "in" + ] + }, + "CounterpartySummary": { + "type": "object", + "required": [ + "account_id", + "direction" + ], + "properties": { + "account_id": { + "type": "string" + }, + "direction": { + "$ref": "#/components/schemas/CounterpartyDirection" + } + } + }, + "CreateProposalRequest": { + "type": "object", + "required": [ + "account_id", + "user_op_hash", + "payload", + "nonce", + "ttl_seconds", + "signature" + ], + "properties": { + "account_id": { + "type": "string" + }, + "nonce": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "ttl_seconds": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "user_op_hash": { + "type": "string" + } + } + }, + "DashboardAccountDetail": { + "type": "object", + "required": [ + "account_id", + "auth_scheme", + "authorized_signer_count", + "authorized_signer_ids", + "has_pending_candidate", + "state_status", + "created_at", + "updated_at" + ], + "properties": { + "account_id": { + "type": "string" + }, + "account_id_bech32": { + "type": [ + "string", + "null" + ] + }, + "auth_scheme": { + "type": "string" + }, + "authorized_signer_count": { + "type": "integer", + "minimum": 0 + }, + "authorized_signer_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "current_commitment": { + "type": [ + "string", + "null" + ] + }, + "has_pending_candidate": { + "type": "boolean" + }, + "paused_at": { + "type": [ + "string", + "null" + ], + "description": "RFC 3339 UTC timestamp of the original pause; `None` when\nactive. Always emitted (active accounts get `null`) for a\nuniform wire shape." + }, + "paused_reason": { + "type": [ + "string", + "null" + ], + "description": "Reason captured at first pause; `None` when active." + }, + "state_created_at": { + "type": [ + "string", + "null" + ] + }, + "state_status": { + "$ref": "#/components/schemas/DashboardAccountStateStatus" + }, + "state_updated_at": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string" + } + } + }, + "DashboardAccountSnapshot": { + "type": "object", + "required": [ + "commitment", + "updated_at", + "has_pending_candidate", + "vault" + ], + "properties": { + "commitment": { + "type": "string", + "description": "Commitment of the state the snapshot was decoded from. Equals\n`DashboardAccountDetail::current_commitment` for the same\naccount at the same point in time; callers can correlate the\nsnapshot with a delta feed entry by matching on this hex." + }, + "has_pending_candidate": { + "type": "boolean", + "description": "True when the account has a candidate delta in flight that has\nnot yet been canonicalized. The snapshot decodes the *current\ncanonical state*, not the candidate's projected state — when\nthis flag is `true` the vault content here may already be\nstale relative to the chain. Clients SHOULD surface this in\nthe UI rather than silently displaying stale data." + }, + "updated_at": { + "type": "string", + "description": "RFC3339 wall-clock time of the underlying state row's\n`updated_at` column — i.e. when Guardian last persisted the\ncanonicalized state this snapshot was decoded from. Equals\n`DashboardAccountDetail::state_updated_at` for the same account\nat the same point in time." + }, + "vault": { + "$ref": "#/components/schemas/DashboardVaultSnapshot" + } + } + }, + "DashboardAccountStateStatus": { + "type": "string", + "enum": [ + "available", + "unavailable" + ] + }, + "DashboardAccountSummary": { + "type": "object", + "required": [ + "account_id", + "auth_scheme", + "authorized_signer_count", + "has_pending_candidate", + "state_status", + "created_at", + "updated_at" + ], + "properties": { + "account_id": { + "type": "string" + }, + "account_id_bech32": { + "type": [ + "string", + "null" + ], + "description": "Bech32m encoding of the Miden `AccountId` using the network's\nHRP (e.g. `mtst...`, `mdev...`, `mm...`). `None` for EVM\naccounts (no bech32 in that addressing scheme) and for any\nMiden `account_id` that fails to parse as a 15-byte id." + }, + "auth_scheme": { + "type": "string" + }, + "authorized_signer_count": { + "type": "integer", + "minimum": 0 + }, + "created_at": { + "type": "string" + }, + "current_commitment": { + "type": [ + "string", + "null" + ] + }, + "has_pending_candidate": { + "type": "boolean" + }, + "paused_at": { + "type": [ + "string", + "null" + ] + }, + "paused_reason": { + "type": [ + "string", + "null" + ] + }, + "state_status": { + "$ref": "#/components/schemas/DashboardAccountStateStatus" + }, + "updated_at": { + "type": "string" + } + } + }, + "DashboardBackendInfo": { + "type": "object", + "description": "Backend configuration snapshot. Stable for the lifetime of the\nprocess; lets operators distinguish a filesystem dev box from a\npostgres-backed prod replica without inspecting environment.", + "required": [ + "storage", + "supported_ack_schemes" + ], + "properties": { + "canonicalization": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DashboardCanonicalizationConfig", + "description": "`None` when running in optimistic-commit mode; `Some(_)` when\nthe canonicalization worker is active." + } + ] + }, + "storage": { + "type": "string", + "description": "`\"filesystem\"` or `\"postgres\"` from the cargo feature flag." + }, + "supported_ack_schemes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Acknowledgement signature schemes wired into the server's\n`AckRegistry`. Stable order (alphabetic) so clients can rely on\nthe listing." + } + } + }, + "DashboardBuildInfo": { + "type": "object", + "description": "Build identity for the running `guardian-server` binary. Values are\nstable for the lifetime of the process; surfaced so operators can\nconfirm which version/SHA is responding without reading logs.", + "required": [ + "version", + "git_commit", + "profile", + "started_at" + ], + "properties": { + "git_commit": { + "type": "string", + "description": "Short git SHA at build time. `\"unknown\"` when neither\n`GUARDIAN_GIT_SHA` nor a working tree git repo were available\nto `build.rs`." + }, + "profile": { + "type": "string", + "description": "`\"debug\"` or `\"release\"` based on `cfg!(debug_assertions)`." + }, + "started_at": { + "type": "string", + "description": "Wall-clock time the server initialized its dashboard state." + }, + "version": { + "type": "string", + "description": "`CARGO_PKG_VERSION` from `guardian-server`." + } + } + }, + "DashboardCanonicalizationConfig": { + "type": "object", + "description": "Per-account-method canonicalization fan-in configuration. `None`\nmeans the server is running in optimistic mode (deltas are written\ndirectly as canonical and never enter the candidate state).", + "required": [ + "check_interval_seconds", + "max_retries", + "submission_grace_period_seconds" + ], + "properties": { + "check_interval_seconds": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "max_retries": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "submission_grace_period_seconds": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "DashboardDeltaCategory": { + "type": "string", + "description": "Closed, stable enumeration of action categories. Adding a value is\na wire-contract change.", + "enum": [ + "asset_transfer", + "note_consumption", + "note_creation", + "account_storage_change", + "guardian_switch", + "custom" + ] + }, + "DashboardDeltaDetail": { + "type": "object", + "description": "Wire shape for `GET /dashboard/accounts/{account_id}/deltas/{nonce}`.\n\n`category` and `proposal` are spread to L1 from the persisted\nmetadata column. `note_counts`, `asset`, and `counterparty` are\nintentionally omitted — they are derivable from the per-section\narrays below.", + "required": [ + "account_id", + "nonce", + "status", + "status_timestamp", + "prev_commitment", + "input_notes", + "output_notes", + "vault_changes", + "storage_changes" + ], + "properties": { + "account_id": { + "type": "string" + }, + "category": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DashboardDeltaCategory" + } + ] + }, + "decode_warnings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecodeWarning" + }, + "description": "Non-empty when one or more sections could not be decoded. The\nrequest still returns 200; affected sections are empty." + }, + "input_notes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecodedNote" + } + }, + "new_commitment": { + "type": [ + "string", + "null" + ] + }, + "nonce": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "output_notes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecodedNote" + } + }, + "prev_commitment": { + "type": "string" + }, + "proposal": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ProposalMetadata" + } + ] + }, + "raw_transaction_summary": { + "type": [ + "string", + "null" + ], + "description": "Base64-encoded raw `TransactionSummary` blob. Present only\nwhen the caller requested `?include=raw` (debug-only)." + }, + "retry_count": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "status": { + "$ref": "#/components/schemas/DashboardDeltaStatus" + }, + "status_timestamp": { + "type": "string" + }, + "storage_changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StorageChange" + } + }, + "vault_changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VaultChange" + } + } + } + }, + "DashboardDeltaEntry": { + "type": "object", + "description": "One entry in the delta feed wire shape per `data-model.md`.\n`account_id` is omitted on per-account responses (the path scopes\nit). The global delta feed (Phase 8) wraps this struct with\n`account_id` so a single shape is shared.", + "required": [ + "nonce", + "status", + "status_timestamp", + "prev_commitment" + ], + "properties": { + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetSummary" + } + }, + "category": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DashboardDeltaCategory", + "description": "Spread from the persisted `DeltaMetadata` column. `None` for\nrows that predate the push-time pipeline or carry an undecodable\npayload (EVM, schema drift) — clients render as \"metadata\nunavailable\"." + } + ] + }, + "counterparty": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CounterpartySummary" + } + ] + }, + "new_commitment": { + "type": [ + "string", + "null" + ], + "description": "`None` is serialized as `null` rather than skipped, since the\nspec exposes `new_commitment: string | null` (e.g. for a\ndiscarded delta that did not produce a resulting commitment)." + }, + "nonce": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "note_counts": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NoteCounts" + } + ] + }, + "prev_commitment": { + "type": "string" + }, + "proposal_type": { + "type": [ + "string", + "null" + ], + "description": "Operator's fine-grained intent label\n(`metadata.proposal.proposal_type`). Full proposal block lives\non the detail endpoint." + }, + "retry_count": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Always `Some(_)` on candidate entries (default `0` per FR-015);\n`None` and skipped on `canonical` / `discarded`.", + "minimum": 0 + }, + "status": { + "$ref": "#/components/schemas/DashboardDeltaStatus" + }, + "status_timestamp": { + "type": "string" + } + } + }, + "DashboardDeltaStatus": { + "type": "string", + "description": "Lifecycle status surfaced on the per-account delta feed endpoint.\n`pending`-status records live in `delta_proposals` and are\nsurfaced via the proposal queue endpoint instead.", + "enum": [ + "candidate", + "canonical", + "discarded" + ] + }, + "DashboardDeltaStatusCounts": { + "type": "object", + "required": [ + "candidate", + "canonical", + "discarded" + ], + "properties": { + "candidate": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "canonical": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "discarded": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "DashboardGlobalDeltaEntry": { + "type": "object", + "description": "One entry in the global delta feed wire shape per `data-model.md`.\nCarries every field of a per-account [`DashboardDeltaEntry`] plus\n`account_id` so the dashboard can group / link without a second\nrequest.", + "required": [ + "account_id", + "nonce", + "status", + "status_timestamp", + "prev_commitment" + ], + "properties": { + "account_id": { + "type": "string" + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetSummary" + } + }, + "category": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DashboardDeltaCategory" + } + ] + }, + "counterparty": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CounterpartySummary" + } + ] + }, + "new_commitment": { + "type": [ + "string", + "null" + ] + }, + "nonce": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "note_counts": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NoteCounts" + } + ] + }, + "prev_commitment": { + "type": "string" + }, + "proposal_type": { + "type": [ + "string", + "null" + ] + }, + "retry_count": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "status": { + "$ref": "#/components/schemas/DashboardDeltaStatus" + }, + "status_timestamp": { + "type": "string" + } + } + }, + "DashboardGlobalProposalEntry": { + "type": "object", + "description": "One entry in the global proposal feed wire shape per\n`data-model.md`. Includes every field of the per-account\nproposal entry plus `account_id`.", + "required": [ + "account_id", + "commitment", + "nonce", + "proposer_id", + "originating_timestamp", + "signatures_collected", + "signatures_required", + "prev_commitment" + ], + "properties": { + "account_id": { + "type": "string" + }, + "commitment": { + "type": "string" + }, + "new_commitment": { + "type": [ + "string", + "null" + ] + }, + "nonce": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "originating_timestamp": { + "type": "string" + }, + "prev_commitment": { + "type": "string" + }, + "proposal_type": { + "type": [ + "string", + "null" + ], + "description": "See `DashboardProposalEntry::proposal_type` — in practice always\npopulated for in-flight multisig proposals on this endpoint." + }, + "proposer_id": { + "type": "string" + }, + "signatures_collected": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "signatures_required": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "DashboardInfoResponse": { + "type": "object", + "required": [ + "service_status", + "environment", + "build", + "backend", + "total_account_count", + "accounts_by_auth_method", + "delta_status_counts", + "in_flight_proposal_count", + "degraded_aggregates" + ], + "properties": { + "accounts_by_auth_method": { + "type": "object", + "description": "Counts of accounts grouped by stable `Auth::method_label()`.\nKeys never collide with internal enum names. Absent when the\naggregate is marked degraded (see `degraded_aggregates`).", + "additionalProperties": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "propertyNames": { + "type": "string" + } + }, + "backend": { + "$ref": "#/components/schemas/DashboardBackendInfo" + }, + "build": { + "$ref": "#/components/schemas/DashboardBuildInfo" + }, + "degraded_aggregates": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of aggregates that returned a degraded marker on this\nresponse. Stable strings — clients branch on these to decide\nwhether to retry or rely on the partial value." + }, + "delta_status_counts": { + "$ref": "#/components/schemas/DashboardDeltaStatusCounts" + }, + "environment": { + "type": "string" + }, + "in_flight_proposal_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "latest_activity": { + "type": [ + "string", + "null" + ], + "description": "Greater of the most recent delta status timestamp and the most\nrecent proposal originating timestamp across all accounts;\n`None` (serialized as `null`) when the inventory has produced\nno activity yet, OR when this aggregate is degraded." + }, + "service_status": { + "$ref": "#/components/schemas/DashboardServiceStatus" + }, + "total_account_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "DashboardProposalEntry": { + "type": "object", + "description": "One proposal entry in the wire shape per `data-model.md`.\n`account_id` is omitted on per-account responses; the global\nproposal feed (Phase 9) wraps this with `account_id`.", + "required": [ + "commitment", + "nonce", + "proposer_id", + "originating_timestamp", + "signatures_collected", + "signatures_required", + "prev_commitment" + ], + "properties": { + "commitment": { + "type": "string", + "description": "Cryptographic identifier cosigners are signing. Per-account\nstable identifier." + }, + "new_commitment": { + "type": [ + "string", + "null" + ], + "description": "Hex string when present; `null` for proposals that did not\ndeclare a target commitment." + }, + "nonce": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "originating_timestamp": { + "type": "string" + }, + "prev_commitment": { + "type": "string" + }, + "proposal_type": { + "type": [ + "string", + "null" + ], + "description": "Multisig proposal type tag from\n`delta_payload.metadata.proposal_type`. In practice this is\nalways populated for in-flight proposals on this endpoint\n(validated on push); the field is `Option` to remain defensive\nagainst legacy or malformed records." + }, + "proposer_id": { + "type": "string" + }, + "signatures_collected": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "signatures_required": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "DashboardServiceStatus": { + "type": "string", + "enum": [ + "healthy", + "degraded" + ] + }, + "DashboardVaultFungibleEntry": { + "type": "object", + "description": "One fungible asset entry in the vault snapshot. `amount` is a string\nto keep `u64`-precision values safe across JS (`Number.MAX_SAFE_INTEGER`\nis 2^53 − 1). Decimal handling is a dashboard-client concern.", + "required": [ + "faucet_id", + "amount" + ], + "properties": { + "amount": { + "type": "string" + }, + "faucet_id": { + "type": "string" + } + } + }, + "DashboardVaultNonFungibleEntry": { + "type": "object", + "description": "One non-fungible asset entry. `vault_key` is the canonical Miden\nidentifier for the asset within the vault.", + "required": [ + "faucet_id", + "vault_key" + ], + "properties": { + "faucet_id": { + "type": "string" + }, + "vault_key": { + "type": "string" + } + } + }, + "DashboardVaultSnapshot": { + "type": "object", + "required": [ + "fungible", + "non_fungible" + ], + "properties": { + "fungible": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DashboardVaultFungibleEntry" + } + }, + "non_fungible": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DashboardVaultNonFungibleEntry" + } + } + } + }, + "DecodeSection": { + "type": "string", + "enum": [ + "tx_summary", + "metadata", + "input_notes", + "output_notes", + "vault", + "storage" + ] + }, + "DecodeWarning": { + "type": "object", + "required": [ + "section", + "reason" + ], + "properties": { + "reason": { + "type": "string" + }, + "section": { + "$ref": "#/components/schemas/DecodeSection" + } + } + }, + "DecodedAsset": { + "type": "object", + "required": [ + "asset_id", + "kind" + ], + "properties": { + "amount": { + "type": [ + "string", + "null" + ] + }, + "asset_id": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/AssetKind" + } + } + }, + "DecodedNote": { + "type": "object", + "description": "Detail-view types used by the per-delta endpoint; not built by the\nlisting path.", + "required": [ + "note_id", + "tag", + "assets" + ], + "properties": { + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecodedAsset" + } + }, + "note_id": { + "type": "string" + }, + "recipient": { + "type": [ + "string", + "null" + ] + }, + "sender": { + "type": [ + "string", + "null" + ] + }, + "tag": { + "$ref": "#/components/schemas/NoteTag" + } + } + }, + "DeltaMetadata": { + "type": "object", + "description": "Persisted activity metadata for a canonical delta. Stored as JSONB\nin the `deltas.metadata` column. `None` for EVM deltas and any\nhistorical row never reprocessed by [`build_metadata`].", + "required": [ + "category" + ], + "properties": { + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetSummary" + }, + "description": "Assets surfaced in deterministic order. Empty when the\ntransaction does not move an asset or extraction failed.\nMulti-asset transactions populate every extractable entry so\nclients do not show a misleading single-asset summary." + }, + "category": { + "$ref": "#/components/schemas/DashboardDeltaCategory" + }, + "counterparty": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CounterpartySummary", + "description": "Counterparty of the transaction. `None` for transactions\nwithout a clear sender/recipient (admin ops, swaps, etc.)." + } + ] + }, + "note_counts": { + "$ref": "#/components/schemas/NoteCounts" + }, + "proposal": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ProposalMetadata", + "description": "Multisig proposal intent lifted from the matching\n`delta_proposals` row at push time. Absent for single-key\n`push_delta`, EVM deltas, and pushes where no proposal matched." + } + ] + } + } + }, + "DeltaObject": { + "type": "object", + "description": "Delta object", + "required": [ + "account_id", + "nonce", + "prev_commitment", + "delta_payload", + "ack_sig", + "ack_pubkey", + "ack_scheme", + "status" + ], + "properties": { + "account_id": { + "type": "string" + }, + "ack_pubkey": { + "type": "string" + }, + "ack_scheme": { + "type": "string" + }, + "ack_sig": { + "type": "string" + }, + "delta_payload": { + "type": "object", + "description": "Opaque, schema-free JSON payload describing the state delta." + }, + "metadata": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeltaMetadata", + "description": "Typed dashboard metadata derived at push time. Stored as JSONB\nin the `deltas.metadata` column. `None` for EVM deltas and any\nhistorical row never reprocessed by the push-time pipeline." + } + ] + }, + "new_commitment": { + "type": [ + "string", + "null" + ] + }, + "nonce": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "prev_commitment": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/DeltaStatus" + } + } + }, + "DeltaProposalRequest": { + "type": "object", + "required": [ + "account_id", + "nonce", + "delta_payload" + ], + "properties": { + "account_id": { + "type": "string" + }, + "delta_payload": { + "type": "object", + "description": "Opaque, schema-free multisig proposal payload." + }, + "nonce": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "DeltaProposalResponse": { + "type": "object", + "required": [ + "delta", + "commitment" + ], + "properties": { + "commitment": { + "type": "string" + }, + "delta": { + "$ref": "#/components/schemas/DeltaObject" + } + } + }, + "DeltaStatus": { + "oneOf": [ + { + "type": "object", + "required": [ + "timestamp", + "proposer_id", + "cosigner_sigs", + "status" + ], + "properties": { + "cosigner_sigs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CosignerSignature" + } + }, + "proposer_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending" + ] + }, + "timestamp": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "timestamp", + "status" + ], + "properties": { + "retry_count": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "status": { + "type": "string", + "enum": [ + "candidate" + ] + }, + "timestamp": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "timestamp", + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "canonical" + ] + }, + "timestamp": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "timestamp", + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "discarded" + ] + }, + "timestamp": { + "type": "string" + } + } + } + ], + "description": "Delta status state machine" + }, + "EvmProposal": { + "type": "object", + "required": [ + "proposal_id", + "account_id", + "chain_id", + "smart_account_address", + "validator_address", + "user_op_hash", + "payload", + "nonce", + "nonce_key", + "proposer", + "signer_snapshot", + "threshold", + "signatures", + "created_at", + "expires_at" + ], + "properties": { + "account_id": { + "type": "string" + }, + "chain_id": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "created_at": { + "type": "integer", + "format": "int64" + }, + "expires_at": { + "type": "integer", + "format": "int64" + }, + "nonce": { + "type": "string" + }, + "nonce_key": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "proposal_id": { + "type": "string" + }, + "proposer": { + "type": "string" + }, + "signatures": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EvmProposalSignature" + } + }, + "signer_snapshot": { + "type": "array", + "items": { + "type": "string" + } + }, + "smart_account_address": { + "type": "string" + }, + "threshold": { + "type": "integer", + "minimum": 0 + }, + "user_op_hash": { + "type": "string" + }, + "validator_address": { + "type": "string" + } + } + }, + "EvmProposalSignature": { + "type": "object", + "required": [ + "signer", + "signature", + "signed_at" + ], + "properties": { + "signature": { + "type": "string" + }, + "signed_at": { + "type": "integer", + "format": "int64" + }, + "signer": { + "type": "string" + } + } + }, + "ExecutableEvmProposal": { + "type": "object", + "required": [ + "hash", + "payload", + "signatures", + "signers" + ], + "properties": { + "hash": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "signatures": { + "type": "array", + "items": { + "type": "string" + } + }, + "signers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ListProposalsResponse": { + "type": "object", + "required": [ + "proposals" + ], + "properties": { + "proposals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EvmProposal" + } + } + } + }, + "LogoutOperatorResponse": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean" + } + } + }, + "LogoutResponse": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean" + } + } + }, + "LookupAccount": { + "type": "object", + "description": "Single match in a lookup response. Wraps `account_id` so the response shape\ncan be extended in a forward-compatible way (e.g. adding role tags or\nper-account metadata) without breaking existing clients.", + "required": [ + "account_id" + ], + "properties": { + "account_id": { + "type": "string" + } + } + }, + "LookupResponse": { + "type": "object", + "required": [ + "accounts" + ], + "properties": { + "accounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LookupAccount" + } + } + } + }, + "MidenNetworkType": { + "type": "string", + "enum": [ + "local", + "devnet", + "testnet" + ] + }, + "NetworkConfig": { + "oneOf": [ + { + "type": "object", + "required": [ + "network_type", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "miden" + ] + }, + "network_type": { + "$ref": "#/components/schemas/MidenNetworkType" + } + } + }, + { + "type": "object", + "required": [ + "chain_id", + "account_address", + "multisig_validator_address", + "kind" + ], + "properties": { + "account_address": { + "type": "string" + }, + "chain_id": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "kind": { + "type": "string", + "enum": [ + "evm" + ] + }, + "multisig_validator_address": { + "type": "string" + } + } + } + ] + }, + "NoteCounts": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "output": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "NoteTag": { + "type": "string", + "enum": [ + "p2id", + "p2ide", + "pswap", + "mint", + "burn", + "custom" + ] + }, + "OperatorChallengeResponse": { + "type": "object", + "required": [ + "success", + "challenge" + ], + "properties": { + "challenge": { + "$ref": "#/components/schemas/OperatorChallengeView" + }, + "success": { + "type": "boolean" + } + } + }, + "OperatorChallengeView": { + "type": "object", + "required": [ + "domain", + "commitment", + "nonce", + "expires_at", + "signing_digest" + ], + "properties": { + "commitment": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "nonce": { + "type": "string" + }, + "signing_digest": { + "type": "string" + } + } + }, + "PagedResult_DashboardAccountSummary": { + "type": "object", + "description": "Standard cursor-pagination envelope returned by every paginated\nendpoint. `next_cursor` is `None` at end of list.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "required": [ + "account_id", + "auth_scheme", + "authorized_signer_count", + "has_pending_candidate", + "state_status", + "created_at", + "updated_at" + ], + "properties": { + "account_id": { + "type": "string" + }, + "account_id_bech32": { + "type": [ + "string", + "null" + ], + "description": "Bech32m encoding of the Miden `AccountId` using the network's\nHRP (e.g. `mtst...`, `mdev...`, `mm...`). `None` for EVM\naccounts (no bech32 in that addressing scheme) and for any\nMiden `account_id` that fails to parse as a 15-byte id." + }, + "auth_scheme": { + "type": "string" + }, + "authorized_signer_count": { + "type": "integer", + "minimum": 0 + }, + "created_at": { + "type": "string" + }, + "current_commitment": { + "type": [ + "string", + "null" + ] + }, + "has_pending_candidate": { + "type": "boolean" + }, + "paused_at": { + "type": [ + "string", + "null" + ] + }, + "paused_reason": { + "type": [ + "string", + "null" + ] + }, + "state_status": { + "$ref": "#/components/schemas/DashboardAccountStateStatus" + }, + "updated_at": { + "type": "string" + } + } + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + } + }, + "PagedResult_DashboardDeltaEntry": { + "type": "object", + "description": "Standard cursor-pagination envelope returned by every paginated\nendpoint. `next_cursor` is `None` at end of list.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "description": "One entry in the delta feed wire shape per `data-model.md`.\n`account_id` is omitted on per-account responses (the path scopes\nit). The global delta feed (Phase 8) wraps this struct with\n`account_id` so a single shape is shared.", + "required": [ + "nonce", + "status", + "status_timestamp", + "prev_commitment" + ], + "properties": { + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetSummary" + } + }, + "category": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DashboardDeltaCategory", + "description": "Spread from the persisted `DeltaMetadata` column. `None` for\nrows that predate the push-time pipeline or carry an undecodable\npayload (EVM, schema drift) — clients render as \"metadata\nunavailable\"." + } + ] + }, + "counterparty": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CounterpartySummary" + } + ] + }, + "new_commitment": { + "type": [ + "string", + "null" + ], + "description": "`None` is serialized as `null` rather than skipped, since the\nspec exposes `new_commitment: string | null` (e.g. for a\ndiscarded delta that did not produce a resulting commitment)." + }, + "nonce": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "note_counts": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NoteCounts" + } + ] + }, + "prev_commitment": { + "type": "string" + }, + "proposal_type": { + "type": [ + "string", + "null" + ], + "description": "Operator's fine-grained intent label\n(`metadata.proposal.proposal_type`). Full proposal block lives\non the detail endpoint." + }, + "retry_count": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Always `Some(_)` on candidate entries (default `0` per FR-015);\n`None` and skipped on `canonical` / `discarded`.", + "minimum": 0 + }, + "status": { + "$ref": "#/components/schemas/DashboardDeltaStatus" + }, + "status_timestamp": { + "type": "string" + } + } + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + } + }, + "PagedResult_DashboardGlobalDeltaEntry": { + "type": "object", + "description": "Standard cursor-pagination envelope returned by every paginated\nendpoint. `next_cursor` is `None` at end of list.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "description": "One entry in the global delta feed wire shape per `data-model.md`.\nCarries every field of a per-account [`DashboardDeltaEntry`] plus\n`account_id` so the dashboard can group / link without a second\nrequest.", + "required": [ + "account_id", + "nonce", + "status", + "status_timestamp", + "prev_commitment" + ], + "properties": { + "account_id": { + "type": "string" + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetSummary" + } + }, + "category": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DashboardDeltaCategory" + } + ] + }, + "counterparty": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CounterpartySummary" + } + ] + }, + "new_commitment": { + "type": [ + "string", + "null" + ] + }, + "nonce": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "note_counts": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NoteCounts" + } + ] + }, + "prev_commitment": { + "type": "string" + }, + "proposal_type": { + "type": [ + "string", + "null" + ] + }, + "retry_count": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "status": { + "$ref": "#/components/schemas/DashboardDeltaStatus" + }, + "status_timestamp": { + "type": "string" + } + } + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + } + }, + "PagedResult_DashboardGlobalProposalEntry": { + "type": "object", + "description": "Standard cursor-pagination envelope returned by every paginated\nendpoint. `next_cursor` is `None` at end of list.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "description": "One entry in the global proposal feed wire shape per\n`data-model.md`. Includes every field of the per-account\nproposal entry plus `account_id`.", + "required": [ + "account_id", + "commitment", + "nonce", + "proposer_id", + "originating_timestamp", + "signatures_collected", + "signatures_required", + "prev_commitment" + ], + "properties": { + "account_id": { + "type": "string" + }, + "commitment": { + "type": "string" + }, + "new_commitment": { + "type": [ + "string", + "null" + ] + }, + "nonce": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "originating_timestamp": { + "type": "string" + }, + "prev_commitment": { + "type": "string" + }, + "proposal_type": { + "type": [ + "string", + "null" + ], + "description": "See `DashboardProposalEntry::proposal_type` — in practice always\npopulated for in-flight multisig proposals on this endpoint." + }, + "proposer_id": { + "type": "string" + }, + "signatures_collected": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "signatures_required": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + } + }, + "PagedResult_DashboardProposalEntry": { + "type": "object", + "description": "Standard cursor-pagination envelope returned by every paginated\nendpoint. `next_cursor` is `None` at end of list.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "description": "One proposal entry in the wire shape per `data-model.md`.\n`account_id` is omitted on per-account responses; the global\nproposal feed (Phase 9) wraps this with `account_id`.", + "required": [ + "commitment", + "nonce", + "proposer_id", + "originating_timestamp", + "signatures_collected", + "signatures_required", + "prev_commitment" + ], + "properties": { + "commitment": { + "type": "string", + "description": "Cryptographic identifier cosigners are signing. Per-account\nstable identifier." + }, + "new_commitment": { + "type": [ + "string", + "null" + ], + "description": "Hex string when present; `null` for proposals that did not\ndeclare a target commitment." + }, + "nonce": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "originating_timestamp": { + "type": "string" + }, + "prev_commitment": { + "type": "string" + }, + "proposal_type": { + "type": [ + "string", + "null" + ], + "description": "Multisig proposal type tag from\n`delta_payload.metadata.proposal_type`. In practice this is\nalways populated for in-flight proposals on this endpoint\n(validated on push); the field is `Option` to remain defensive\nagainst legacy or malformed records." + }, + "proposer_id": { + "type": "string" + }, + "signatures_collected": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "signatures_required": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + } + }, + "PauseAccountRequest": { + "type": "object", + "required": [ + "reason" + ], + "properties": { + "reason": { + "type": "string" + } + } + }, + "PauseResponse": { + "type": "object", + "required": [ + "account_id", + "before_state", + "after_state", + "paused_at", + "paused_reason" + ], + "properties": { + "account_id": { + "type": "string" + }, + "after_state": { + "$ref": "#/components/schemas/AccountStatus" + }, + "before_state": { + "$ref": "#/components/schemas/AccountStatus" + }, + "paused_at": { + "type": "string" + }, + "paused_reason": { + "type": "string" + } + } + }, + "ProposalMetadata": { + "type": "object", + "description": "Operator-stated intent lifted from a matching proposal. Mirrors\n`ProposalMetadataPayload` in `crates/miden-multisig-client/src/payload.rs`\nfield-for-field.", + "required": [ + "proposal_type" + ], + "properties": { + "amount": { + "type": [ + "string", + "null" + ] + }, + "consume_notes_metadata_version": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "consume_notes_notes": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "faucet_id": { + "type": [ + "string", + "null" + ] + }, + "new_guardian_endpoint": { + "type": [ + "string", + "null" + ] + }, + "new_guardian_pubkey": { + "type": [ + "string", + "null" + ] + }, + "note_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "proposal_type": { + "type": "string", + "description": "One of the validated multisig proposal types (`add_signer`,\n`remove_signer`, `change_threshold`, `update_procedure_threshold`,\n`p2id`, `consume_notes`, `switch_guardian`). Free string so new\ntypes from the multisig client don't force a wire-contract bump\nhere — `category` is the closed enum." + }, + "recipient_id": { + "type": [ + "string", + "null" + ] + }, + "required_signatures": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "salt": { + "type": [ + "string", + "null" + ] + }, + "signer_commitments": { + "type": "array", + "items": { + "type": "string" + } + }, + "target_procedure": { + "type": [ + "string", + "null" + ] + }, + "target_threshold": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + } + }, + "ProposalSignature": { + "oneOf": [ + { + "type": "object", + "required": [ + "signature", + "scheme" + ], + "properties": { + "scheme": { + "type": "string", + "enum": [ + "falcon" + ] + }, + "signature": { + "type": "string", + "description": "Hex-encoded Falcon signature" + } + } + }, + { + "type": "object", + "required": [ + "signature", + "scheme" + ], + "properties": { + "public_key": { + "type": [ + "string", + "null" + ], + "description": "Hex-encoded ECDSA public key (required for signature preparation)" + }, + "scheme": { + "type": "string", + "enum": [ + "ecdsa" + ] + }, + "signature": { + "type": "string", + "description": "Hex-encoded ECDSA secp256k1 signature" + } + } + } + ], + "description": "Signature type for delta proposals" + }, + "ProposalsResponse": { + "type": "object", + "required": [ + "proposals" + ], + "properties": { + "proposals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeltaObject" + } + } + } + }, + "PubkeyResponse": { + "type": "object", + "required": [ + "commitment" + ], + "properties": { + "commitment": { + "type": "string" + }, + "pubkey": { + "type": [ + "string", + "null" + ] + } + } + }, + "RegisterAccountRequest": { + "type": "object", + "required": [ + "chain_id", + "account_address", + "multisig_validator_address" + ], + "properties": { + "account_address": { + "type": "string" + }, + "chain_id": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "multisig_validator_address": { + "type": "string" + } + } + }, + "RegisterAccountResponse": { + "type": "object", + "required": [ + "account_id", + "chain_id", + "account_address", + "multisig_validator_address", + "signers", + "threshold" + ], + "properties": { + "account_address": { + "type": "string" + }, + "account_id": { + "type": "string" + }, + "chain_id": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "multisig_validator_address": { + "type": "string" + }, + "signers": { + "type": "array", + "items": { + "type": "string" + } + }, + "threshold": { + "type": "integer", + "minimum": 0 + } + } + }, + "SessionInfoResponse": { + "type": "object", + "required": [ + "operator_id", + "permissions" + ], + "properties": { + "operator_id": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "SignProposalRequest": { + "type": "object", + "required": [ + "account_id", + "commitment", + "signature" + ], + "properties": { + "account_id": { + "type": "string" + }, + "commitment": { + "type": "string" + }, + "signature": { + "$ref": "#/components/schemas/ProposalSignature" + } + } + }, + "StateObject": { + "type": "object", + "description": "Account state object", + "required": [ + "account_id", + "state_json", + "commitment", + "created_at", + "updated_at" + ], + "properties": { + "account_id": { + "type": "string" + }, + "auth_scheme": { + "type": "string" + }, + "commitment": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "state_json": { + "type": "object", + "description": "Opaque, schema-free JSON blob describing the account state." + }, + "updated_at": { + "type": "string" + } + } + }, + "StorageChange": { + "type": "object", + "required": [ + "slot_name" + ], + "properties": { + "after": { + "type": [ + "string", + "null" + ], + "description": "Hex-encoded `Word` after the change. `None` when the slot was cleared." + }, + "before": { + "type": [ + "string", + "null" + ], + "description": "Hex-encoded `Word` (64 hex chars + `0x` prefix) before the\nchange. Always omitted in v1 — `TransactionSummary` carries\nonly post-change values; populating `before` requires reading\nstorage at `prev_commitment`." + }, + "key": { + "type": [ + "string", + "null" + ], + "description": "Hex-encoded `Word` map key (64 hex chars + `0x` prefix) for\n`StorageMap` slot entries. `None` for scalar value slots. For the\nmultisig `proc_threshold_overrides` map (slot 4) this is the\nMASM procedure root." + }, + "slot_name": { + "type": "string", + "description": "Human-readable slot name from\n`miden_protocol::account::StorageSlotName` (e.g. `\"consumed_notes\"`).\nSlots are identified by name in Miden, not by numeric index." + } + } + }, + "UnpauseAccountRequest": { + "type": "object", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + } + } + }, + "UnpauseResponse": { + "type": "object", + "required": [ + "account_id", + "before_state", + "after_state" + ], + "properties": { + "account_id": { + "type": "string" + }, + "after_state": { + "$ref": "#/components/schemas/AccountStatus" + }, + "before_state": { + "$ref": "#/components/schemas/AccountStatus" + }, + "reason": { + "type": [ + "string", + "null" + ] + } + } + }, + "VaultChange": { + "oneOf": [ + { + "type": "object", + "required": [ + "asset_id", + "change", + "kind" + ], + "properties": { + "asset_id": { + "type": "string" + }, + "change": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "fungible" + ] + } + } + }, + { + "type": "object", + "required": [ + "asset_id", + "added", + "removed", + "kind" + ], + "properties": { + "added": { + "type": "array", + "items": { + "type": "string" + } + }, + "asset_id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "non_fungible" + ] + }, + "removed": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "VerifyOperatorRequest": { + "type": "object", + "required": [ + "commitment", + "signature" + ], + "properties": { + "commitment": { + "type": "string" + }, + "signature": { + "type": "string" + } + } + }, + "VerifyOperatorResponse": { + "type": "object", + "required": [ + "success", + "operator_id", + "expires_at" + ], + "properties": { + "expires_at": { + "type": "string" + }, + "operator_id": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "VerifySessionRequest": { + "type": "object", + "required": [ + "address", + "nonce", + "signature" + ], + "properties": { + "address": { + "type": "string" + }, + "nonce": { + "type": "string" + }, + "signature": { + "type": "string" + } + } + }, + "VerifySessionResponse": { + "type": "object", + "required": [ + "address", + "expires_at" + ], + "properties": { + "address": { + "type": "string" + }, + "expires_at": { + "type": "integer", + "format": "int64" + } + } + } + } + }, + "tags": [ + { + "name": "client", + "description": "Client-facing API consumed by SDKs and packages." + }, + { + "name": "dashboard", + "description": "Operator dashboard API." + }, + { + "name": "evm", + "description": "EVM smart-account API (available when the `evm` feature is enabled)." + } + ] +}