Skip to main content

bsv_wallet_cli/server/
mod.rs

1pub mod audit;
2pub mod broadcast_follow_up;
3pub mod handlers;
4pub mod post_word;
5pub mod types;
6
7use anyhow::Result;
8use axum::extract::{DefaultBodyLimit, Request};
9use axum::http::{HeaderMap, StatusCode};
10use axum::middleware::{self, Next};
11use axum::response::{IntoResponse, Response};
12use axum::routing::{get, post};
13use axum::{Json, Router};
14use bsv_wallet_toolbox::{Chain, Services, StorageSqlx, Wallet};
15use serde_json::json;
16use std::net::SocketAddr;
17use std::sync::Arc;
18use tower_http::cors::CorsLayer;
19use tower_http::trace::TraceLayer;
20
21use handlers::WalletState;
22
23/// Lock that serializes spending operations (createAction).
24/// Non-spending endpoints (encrypt, getPublicKey, listOutputs, etc.) are unaffected.
25///
26/// Why: With limited UTXOs, concurrent createAction calls race on SQLite's write lock
27/// and the loser gets SQLITE_BUSY_SNAPSHOT instead of waiting for the winner's change
28/// output. This lock queues them so each spending request sees the previous one's change.
29pub type SpendingLock = Arc<tokio::sync::Mutex<()>>;
30
31/// TLS configuration (cert + key paths).
32#[derive(Clone)]
33#[allow(dead_code)]
34pub struct TlsConfig {
35    pub cert_path: String,
36    pub key_path: String,
37}
38
39/// Server configuration (auth, TLS, etc.)
40#[derive(Clone)]
41pub struct ServerConfig {
42    /// Optional bearer token. When set, all requests must include
43    /// `Authorization: Bearer <token>`. When None, auth is disabled.
44    pub auth_token: Option<String>,
45    /// Optional TLS config. Requires `--features tls` at build time.
46    pub tls: Option<TlsConfig>,
47    /// Network the served wallet is on. Drives post-broadcast verification
48    /// (which ARC / WoC endpoints to probe). Defaults to `Main`.
49    pub chain: Chain,
50    /// Address to bind (default `127.0.0.1`). Set `BIND_ADDR=0.0.0.0` for the
51    /// tunnel/public-webhook case (`POST /arc-callback` behind cloudflared /
52    /// tailscale funnel / direct TLS).
53    pub bind_addr: std::net::IpAddr,
54    /// Per-wallet ARC/Arcade callback token. When set, `POST /arc-callback`
55    /// is enabled, authenticated by THIS token (`Authorization: Bearer` or
56    /// `X-CallbackToken`) and EXEMPT from the wallet bearer auth above.
57    pub callback_token: Option<String>,
58}
59
60impl Default for ServerConfig {
61    fn default() -> Self {
62        Self {
63            auth_token: None,
64            tls: None,
65            chain: Chain::Main,
66            bind_addr: std::net::IpAddr::from([127, 0, 0, 1]),
67            callback_token: None,
68        }
69    }
70}
71
72/// The address to bind: `BIND_ADDR` when it parses, else `127.0.0.1`. Read
73/// by every served command and by the bind guard, so the two agree.
74pub fn bind_addr_from_env() -> std::net::IpAddr {
75    std::env::var("BIND_ADDR")
76        .ok()
77        .and_then(|v| v.parse().ok())
78        .unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1]))
79}
80
81/// The bind guard (0.7.1). With no bearer token a served wallet answers any
82/// caller that can reach it, and its two BEEF doors take a body of any size
83/// (0.7.0), so an address beyond loopback needs a token or the operator's
84/// explicit `allow_no_token`. An empty token counts as none. Loopback is
85/// unchanged. Run at startup, before the wallet is opened or a socket bound.
86pub fn refuse_open_bind(
87    bind_addr: std::net::IpAddr,
88    auth_token: Option<&str>,
89    allow_no_token: bool,
90) -> Result<()> {
91    let has_token = auth_token.is_some_and(|t| !t.is_empty());
92    if has_token || allow_no_token || bind_addr.to_canonical().is_loopback() {
93        return Ok(());
94    }
95    anyhow::bail!(
96        "refusing to bind {bind_addr}: no bearer token is configured, and a wallet served \
97         beyond loopback without one answers anyone who can reach it. Set AUTH_TOKEN, or pass \
98         --allow-no-token to bind it open deliberately."
99    )
100}
101
102/// [`refuse_open_bind`] on the environment the served commands read
103/// (`BIND_ADDR`, `AUTH_TOKEN`).
104pub fn refuse_open_bind_from_env(allow_no_token: bool) -> Result<()> {
105    let token = std::env::var("AUTH_TOKEN").ok();
106    refuse_open_bind(bind_addr_from_env(), token.as_deref(), allow_no_token)
107}
108
109/// Auth middleware — checks Bearer token if configured.
110async fn auth_middleware(headers: HeaderMap, request: Request, next: Next) -> Response {
111    // /arc-callback is EXEMPT from wallet bearer auth: it is authenticated by
112    // the per-wallet callback token inside its own handler (ARC/Arcade call it
113    // with `Authorization: Bearer <callback-token>`, not the wallet token).
114    if request.uri().path() == "/arc-callback" {
115        return next.run(request).await;
116    }
117
118    // Extract config from request extensions
119    let token = request
120        .extensions()
121        .get::<ServerConfig>()
122        .and_then(|c| c.auth_token.clone());
123
124    if let Some(expected) = token {
125        let provided = headers
126            .get("authorization")
127            .and_then(|v| v.to_str().ok())
128            .and_then(|v| v.strip_prefix("Bearer "));
129
130        match provided {
131            Some(t) if t == expected => {}
132            _ => {
133                return (
134                    StatusCode::UNAUTHORIZED,
135                    Json(json!({"code": "UNAUTHORIZED", "message": "Invalid or missing bearer token"})),
136                )
137                    .into_response();
138            }
139        }
140    }
141
142    next.run(request).await
143}
144
145/// Build the axum Router with all 28 WalletInterface endpoints.
146pub fn make_router(wallet: WalletState, config: ServerConfig) -> Router {
147    let cors = CorsLayer::very_permissive();
148    let cfg = config.clone();
149    let spending_lock: SpendingLock = Arc::new(tokio::sync::Mutex::new(()));
150    // Post-broadcast verifier — lets `/createAction` fail loudly when ARC
151    // silently dropped an immediate broadcast (e.g. 465 fee-too-low on a deep
152    // unconfirmed BEEF) instead of returning a phantom txid.
153    let verifier = crate::broadcast_verify::BroadcastVerifier::from_env(config.chain);
154
155    Router::new()
156        // Existing 5 endpoints
157        // Status endpoints accept BOTH GET and POST: @bsv/sdk's HTTPWalletJSON substrate
158        // POSTs every method (incl. these), while curl/CLI clients GET them. Supporting
159        // both makes the server wire-compatible with browser WalletClient connections.
160        .route(
161            "/isAuthenticated",
162            get(handlers::is_authenticated).post(handlers::is_authenticated),
163        )
164        .route("/getPublicKey", post(handlers::get_public_key))
165        // PERMISSION AUDIT (not BRC-100): what a wallet with a human behind it
166        // would have prompted for. See `audit.rs` — this daemon grants everything
167        // silently, which is what makes it headless AND blind.
168        .route("/permission-audit", get(permission_audit))
169        .route("/permission-audit/reset", post(permission_audit_reset))
170        // CHAIN FEED (not BRC-100): the daemon's chain-event feed status,
171        // its stored cursor and, when it stopped, the fault it stopped on.
172        .route("/chain-feed", get(chain_feed_status))
173        // THE TRACKER'S WORD (not BRC-100): one transaction's stored word,
174        // its height, its pending re-ask and, for `mined`, the stored
175        // proof's root and height. Behind the same auth as `/chain-feed`.
176        .route("/tx/{txid}", get(tx_word))
177        .route("/createSignature", post(handlers::create_signature))
178        // The two doors that carry a caller's BEEF (`inputBEEF`, `tx`): no
179        // body cap, a valid BEEF is never refused for its size (see
180        // `BEEF_DOORS`).
181        .route(
182            "/createAction",
183            post(handlers::create_action).layer(DefaultBodyLimit::disable()),
184        )
185        .route(
186            "/internalizeAction",
187            post(handlers::internalize_action).layer(DefaultBodyLimit::disable()),
188        )
189        // Batch 1: Status (GET + POST for HTTPWalletJSON compat)
190        .route(
191            "/getHeight",
192            get(handlers::get_height).post(handlers::get_height),
193        )
194        .route(
195            "/getNetwork",
196            get(handlers::get_network).post(handlers::get_network),
197        )
198        .route(
199            "/getVersion",
200            get(handlers::get_version).post(handlers::get_version),
201        )
202        .route(
203            "/waitForAuthentication",
204            get(handlers::wait_for_authentication).post(handlers::wait_for_authentication),
205        )
206        // Batch 2: Header
207        .route("/getHeaderForHeight", post(handlers::get_header_for_height))
208        // Batch 3: Crypto
209        .route("/verifySignature", post(handlers::verify_signature))
210        .route("/encrypt", post(handlers::encrypt))
211        .route("/decrypt", post(handlers::decrypt))
212        .route("/createHmac", post(handlers::create_hmac))
213        .route("/verifyHmac", post(handlers::verify_hmac))
214        // Batch 4: Transaction workflow
215        .route("/signAction", post(handlers::sign_action))
216        .route("/abortAction", post(handlers::abort_action))
217        .route("/listActions", post(handlers::list_actions))
218        .route("/listOutputs", post(handlers::list_outputs))
219        .route("/relinquishOutput", post(handlers::relinquish_output))
220        // Batch 5: Certificates
221        .route("/acquireCertificate", post(handlers::acquire_certificate))
222        .route("/listCertificates", post(handlers::list_certificates))
223        .route("/proveCertificate", post(handlers::prove_certificate))
224        .route(
225            "/relinquishCertificate",
226            post(handlers::relinquish_certificate),
227        )
228        // Batch 6: Discovery + Key linkage
229        .route(
230            "/discoverByIdentityKey",
231            post(handlers::discover_by_identity_key),
232        )
233        .route(
234            "/discoverByAttributes",
235            post(handlers::discover_by_attributes),
236        )
237        .route(
238            "/revealCounterpartyKeyLinkage",
239            post(handlers::reveal_counterparty_key_linkage),
240        )
241        .route(
242            "/revealSpecificKeyLinkage",
243            post(handlers::reveal_specific_key_linkage),
244        )
245        // ARC/Arcade proof-delivery webhook (callback-token auth, exempt from
246        // wallet bearer auth — see auth_middleware).
247        .route("/arc-callback", post(arc_callback))
248        // The last layer added runs first: body limit, trace, the
249        // extensions, then auth BEFORE the body is read (an uncapped BEEF
250        // door is open only to the wallet's own caller), then the body.
251        .layer(cors)
252        .layer(middleware::from_fn(lenient_json_body))
253        .layer(middleware::from_fn(auth_middleware))
254        .layer(axum::Extension(cfg))
255        .layer(axum::Extension(spending_lock))
256        .layer(axum::Extension(verifier))
257        .layer(TraceLayer::new_for_http())
258        .layer(DefaultBodyLimit::max(BODY_CAP))
259        .with_state(wallet)
260}
261
262/// `POST /arc-callback` — ARC/Arcade status webhook receiving push status
263/// updates and (on MINED) the merkle path, straight into wallet storage.
264///
265/// Authenticated by the per-wallet callback token: ARC-convention
266/// `Authorization: Bearer <callback-token>` or an `X-CallbackToken` header
267/// (both accepted — broadcaster implementations vary). Returns 404 when no
268/// callback token is configured (route effectively disabled).
269async fn arc_callback(
270    axum::extract::State(wallet): axum::extract::State<WalletState>,
271    request: Request,
272) -> Response {
273    let expected = request
274        .extensions()
275        .get::<ServerConfig>()
276        .and_then(|c| c.callback_token.clone());
277    let Some(expected) = expected else {
278        return (
279            StatusCode::NOT_FOUND,
280            Json(json!({"code": "CALLBACK_DISABLED", "message": "no callback token configured"})),
281        )
282            .into_response();
283    };
284
285    let headers = request.headers().clone();
286    let provided = headers
287        .get("authorization")
288        .and_then(|v| v.to_str().ok())
289        .and_then(|v| v.strip_prefix("Bearer "))
290        .map(str::to_string)
291        .or_else(|| {
292            headers
293                .get("x-callbacktoken")
294                .and_then(|v| v.to_str().ok())
295                .map(str::to_string)
296        });
297
298    if provided.as_deref() != Some(expected.as_str()) {
299        // Arcade authenticates its webhook POSTs as `Authorization: Bearer
300        // <X-CallbackToken>` — a 401 here means a token mismatch and Arcade
301        // will burn its (~10) retries, then permanently drop the submission.
302        tracing::warn!(
303            has_authorization = headers.contains_key("authorization"),
304            has_x_callbacktoken = headers.contains_key("x-callbacktoken"),
305            "arc-callback: rejected POST with invalid/missing callback token"
306        );
307        return (
308            StatusCode::UNAUTHORIZED,
309            Json(json!({"code": "UNAUTHORIZED", "message": "invalid or missing callback token"})),
310        )
311            .into_response();
312    }
313
314    let bytes = match axum::body::to_bytes(request.into_body(), 1_000_000).await {
315        Ok(b) => b,
316        Err(_) => {
317            return (
318                StatusCode::PAYLOAD_TOO_LARGE,
319                Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
320            )
321                .into_response();
322        }
323    };
324    let payload: serde_json::Value = match serde_json::from_slice(&bytes) {
325        Ok(v) => v,
326        Err(e) => {
327            return (
328                StatusCode::BAD_REQUEST,
329                Json(json!({"code": "BAD_JSON", "message": e.to_string()})),
330            )
331                .into_response();
332        }
333    };
334
335    match crate::arc_ingest::ingest_arc_payload(wallet.storage(), &payload).await {
336        Ok(action) => (
337            StatusCode::OK,
338            Json(json!({"ok": true, "action": format!("{:?}", action)})),
339        )
340            .into_response(),
341        Err(e) => (
342            StatusCode::BAD_REQUEST,
343            Json(json!({"code": "BAD_PAYLOAD", "message": e.to_string()})),
344        )
345            .into_response(),
346    }
347}
348
349/// Browser wallets accept JSON whose strings carry lone UTF-16 surrogate escapes
350/// (`"\ud83d"` without its pair); serde does not, so a page whose keyID or data
351/// happened to contain one got `400 Failed to parse the request body as JSON:
352/// keyID: lone leading surrogate` from this wallet while MetaNet Desktop served
353/// the same call (beta soak, 2026-09-02, two logins lost). A browser's encoder
354/// turns such a code unit into U+FFFD when it hashes the string, so the same
355/// substitution here yields the same bytes the JS wallet would derive from.
356pub fn sanitize_lone_surrogates(input: &[u8]) -> std::borrow::Cow<'_, [u8]> {
357    if !input.windows(2).any(|w| w == b"\\u") {
358        return std::borrow::Cow::Borrowed(input);
359    }
360    let hex4 = |b: &[u8]| -> Option<u32> {
361        if b.len() < 4 {
362            return None;
363        }
364        std::str::from_utf8(&b[..4])
365            .ok()
366            .and_then(|h| u32::from_str_radix(h, 16).ok())
367    };
368    let mut out = Vec::with_capacity(input.len());
369    let mut i = 0;
370    let mut changed = false;
371    while i < input.len() {
372        if input[i] == b'\\' && i + 1 < input.len() && input[i + 1] == b'u' {
373            if let Some(cu) = hex4(&input[i + 2..]) {
374                let is_lead = (0xD800..=0xDBFF).contains(&cu);
375                let is_trail = (0xDC00..=0xDFFF).contains(&cu);
376                if is_lead {
377                    let next = &input[i + 6..];
378                    let paired = next.len() >= 6
379                        && next[0] == b'\\'
380                        && next[1] == b'u'
381                        && hex4(&next[2..]).is_some_and(|t| (0xDC00..=0xDFFF).contains(&t));
382                    if paired {
383                        out.extend_from_slice(&input[i..i + 12]);
384                        i += 12;
385                        continue;
386                    }
387                    out.extend_from_slice(b"\\ufffd");
388                    changed = true;
389                    i += 6;
390                    continue;
391                }
392                if is_trail {
393                    out.extend_from_slice(b"\\ufffd");
394                    changed = true;
395                    i += 6;
396                    continue;
397                }
398                out.extend_from_slice(&input[i..i + 6]);
399                i += 6;
400                continue;
401            }
402        }
403        out.push(input[i]);
404        i += 1;
405    }
406    if changed {
407        std::borrow::Cow::Owned(out)
408    } else {
409        std::borrow::Cow::Borrowed(input)
410    }
411}
412
413/// The routes whose JSON body carries a caller's BEEF. Their body is read
414/// whole with no cap: the BRC-100 JSON wire carries the BEEF as one array, so
415/// the body is held in memory (linear in the BEEF, about four bytes of body
416/// per byte of BEEF) and the BEEF is then judged by the toolbox's streaming
417/// reader for invalid bytes only. Every other route keeps
418/// [`BODY_CAP`].
419const BEEF_DOORS: [&str; 2] = ["/createAction", "/internalizeAction"];
420
421/// The body cap of the routes that carry no BEEF.
422const BODY_CAP: usize = 50 * 1024 * 1024;
423
424/// Rewrite lone surrogate escapes in JSON request bodies before the `Json`
425/// extractors see them (see [`sanitize_lone_surrogates`]).
426async fn lenient_json_body(request: Request, next: Next) -> Response {
427    let is_json = request
428        .headers()
429        .get(axum::http::header::CONTENT_TYPE)
430        .and_then(|v| v.to_str().ok())
431        .is_some_and(|ct| ct.to_ascii_lowercase().contains("json"));
432    if !is_json {
433        return next.run(request).await;
434    }
435    let (parts, body) = request.into_parts();
436    let cap = if BEEF_DOORS.contains(&parts.uri.path()) {
437        usize::MAX
438    } else {
439        BODY_CAP
440    };
441    let bytes = match axum::body::to_bytes(body, cap).await {
442        Ok(b) => b,
443        Err(_) => {
444            return (
445                StatusCode::PAYLOAD_TOO_LARGE,
446                Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
447            )
448                .into_response();
449        }
450    };
451    let body = match sanitize_lone_surrogates(&bytes) {
452        std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
453        std::borrow::Cow::Owned(fixed) => {
454            tracing::warn!(
455                path = %parts.uri.path(),
456                "json body carried a lone UTF-16 surrogate escape; substituted U+FFFD"
457            );
458            axum::body::Body::from(fixed)
459        }
460    };
461    next.run(Request::from_parts(parts, body)).await
462}
463
464pub async fn run(wallet: WalletState, port: u16, config: ServerConfig) -> Result<()> {
465    let tls = config.tls.clone();
466    let bind_addr = config.bind_addr;
467    let app = make_router(wallet, config);
468    let addr = SocketAddr::from((bind_addr, port));
469
470    #[cfg(feature = "tls")]
471    if let Some(tls_cfg) = tls {
472        use axum_server::tls_rustls::RustlsConfig;
473        let rustls = RustlsConfig::from_pem_file(&tls_cfg.cert_path, &tls_cfg.key_path).await?;
474        tracing::info!("HTTPS server listening on {addr}");
475        eprintln!("HTTPS server listening on {addr}");
476        axum_server::bind_rustls(addr, rustls)
477            .serve(app.into_make_service())
478            .await?;
479        return Ok(());
480    }
481
482    #[cfg(not(feature = "tls"))]
483    if tls.is_some() {
484        anyhow::bail!("TLS requested but binary was built without `--features tls`");
485    }
486
487    tracing::info!("HTTP server listening on {addr}");
488    eprintln!("HTTP server listening on {addr}");
489
490    let listener = tokio::net::TcpListener::bind(addr).await?;
491    axum::serve(listener, app)
492        .with_graceful_shutdown(shutdown_signal())
493        .await?;
494
495    Ok(())
496}
497
498pub fn make_wallet_state(wallet: Wallet<StorageSqlx, Services>) -> WalletState {
499    Arc::new(wallet)
500}
501
502/// Build a `WalletState` from an existing `Arc<Wallet>`.
503///
504/// Use this when the caller is already holding an `Arc<Wallet>` and wants the
505/// HTTP server to share the *same* wallet instance — same identity key, same
506/// balance, same UTXO set. Avoids opening a second `Wallet` handle against
507/// the same SQLite DB (which would work via busy_timeout but adds write
508/// contention and a second instance of any in-memory caches).
509#[allow(dead_code)] // public API for external callers; not used inside this binary
510pub fn make_wallet_state_from_arc(wallet: Arc<Wallet<StorageSqlx, Services>>) -> WalletState {
511    wallet
512}
513
514async fn shutdown_signal() {
515    tokio::signal::ctrl_c()
516        .await
517        .expect("failed to listen for ctrl-c");
518    eprintln!("\nShutting down...");
519}
520
521/// GET /permission-audit — every permissioned request since start or reset.
522async fn permission_audit() -> Json<serde_json::Value> {
523    let entries = audit::snapshot();
524    Json(json!({ "count": entries.len(), "entries": entries }))
525}
526
527/// GET /chain-feed — the feed's stored status and cursor (`chain_feed`).
528/// `{"status": null}` where no feed ran (`serve`, or `CHAINTRACKS_URL=off`).
529async fn chain_feed_status(
530    axum::extract::State(wallet): axum::extract::State<WalletState>,
531) -> Response {
532    let storage = wallet.storage();
533    let status = crate::chain_feed::read_status(storage).await;
534    let cursor = crate::tracker_host::feed_cursor(storage).await;
535    let frozen = crate::tracker_host::frozen_count(storage.pool()).await;
536    match (status, cursor, frozen) {
537        (Ok(status), Ok(cursor), Ok(frozen)) => Json(json!({
538            "status": status,
539            "cursor": cursor.to_string(),
540            "frozenCount": frozen,
541        }))
542        .into_response(),
543        (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => (
544            StatusCode::INTERNAL_SERVER_ERROR,
545            Json(json!({ "code": "ERR_CHAIN_FEED", "message": e.to_string() })),
546        )
547            .into_response(),
548    }
549}
550
551/// GET /tx/{txid} — the tracker's stored word for one transaction
552/// (`tracker_host::stored_word`): `{txid, word, height, reask, evidence}`.
553/// `evidence` is, for a `mined` word, the stored proof's root and height
554/// (the toolbox's `proven_txs` row the spend gate reads), else `null`. A
555/// view for a person or an agent; nothing is decided from it. 404 when the
556/// tracker holds no word for the txid.
557async fn tx_word(
558    axum::extract::State(wallet): axum::extract::State<WalletState>,
559    axum::extract::Path(txid): axum::extract::Path<String>,
560) -> Response {
561    let txid = txid.trim().to_ascii_lowercase();
562    if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
563        return (
564            StatusCode::BAD_REQUEST,
565            Json(
566                json!({ "code": "VALIDATION_ERROR", "message": "txid must be 64 hex characters" }),
567            ),
568        )
569            .into_response();
570    }
571    let storage = wallet.storage();
572    let fault = |e: anyhow::Error| {
573        (
574            StatusCode::INTERNAL_SERVER_ERROR,
575            Json(json!({ "code": "ERR_TX_WORD", "message": e.to_string() })),
576        )
577            .into_response()
578    };
579    let (word, height, reask) =
580        match crate::tracker_host::stored_word(storage, &txid).await {
581            Ok(Some(view)) => view,
582            Ok(None) => return (
583                StatusCode::NOT_FOUND,
584                Json(
585                    json!({ "code": "NOT_FOUND", "message": format!("no word stored for {txid}") }),
586                ),
587            )
588                .into_response(),
589            Err(e) => return fault(e),
590        };
591    let evidence = if word == "mined" {
592        let row: Result<Option<(String, i64)>, sqlx::Error> =
593            sqlx::query_as("SELECT merkle_root, height FROM proven_txs WHERE lower(txid) = ?")
594                .bind(&txid)
595                .fetch_optional(storage.pool())
596                .await;
597        match row {
598            Ok(row) => row.map(|(root, height)| json!({ "root": root, "height": height })),
599            Err(e) => return fault(e.into()),
600        }
601    } else {
602        None
603    };
604    Json(json!({
605        "txid": txid,
606        "word": word,
607        "height": height,
608        "reask": reask,
609        "evidence": evidence,
610    }))
611    .into_response()
612}
613
614/// POST /permission-audit/reset — call immediately before the flow you measure.
615async fn permission_audit_reset() -> Json<serde_json::Value> {
616    audit::reset();
617    Json(json!({ "ok": true }))
618}
619
620#[cfg(test)]
621mod lenient_json_tests {
622    use super::sanitize_lone_surrogates;
623
624    #[test]
625    fn lone_lead_and_lone_trail_become_fffd_and_pairs_survive() {
626        let lead = br#"{"keyID":"ab \ud83d cd"}"#;
627        assert_eq!(
628            &*sanitize_lone_surrogates(lead),
629            br#"{"keyID":"ab \ufffd cd"}"#
630        );
631        let trail = br#"{"keyID":"\udc00"}"#;
632        assert_eq!(&*sanitize_lone_surrogates(trail), br#"{"keyID":"\ufffd"}"#);
633        let pair = br#"{"keyID":"\ud83d\ude00"}"#;
634        assert!(matches!(
635            sanitize_lone_surrogates(pair),
636            std::borrow::Cow::Borrowed(_)
637        ));
638        let plain = br#"{"keyID":"plain \\u0041"}"#;
639        assert!(matches!(
640            sanitize_lone_surrogates(plain),
641            std::borrow::Cow::Borrowed(_)
642        ));
643        let fixed = sanitize_lone_surrogates(lead);
644        let v: serde_json::Value = serde_json::from_slice(&fixed).unwrap();
645        assert_eq!(v["keyID"], "ab \u{fffd} cd");
646    }
647}
648
649#[cfg(test)]
650mod bind_guard_tests {
651    use super::refuse_open_bind;
652    use std::net::IpAddr;
653
654    fn ip(s: &str) -> IpAddr {
655        s.parse().unwrap()
656    }
657
658    #[test]
659    fn beyond_loopback_needs_a_token_or_the_flag() {
660        for open in ["0.0.0.0", "::", "192.168.1.10", "10.0.0.1"] {
661            let err = refuse_open_bind(ip(open), None, false).unwrap_err();
662            assert!(
663                err.to_string().contains("--allow-no-token"),
664                "{open}: {err}"
665            );
666            assert!(
667                refuse_open_bind(ip(open), Some(""), false).is_err(),
668                "{open}"
669            );
670            assert!(
671                refuse_open_bind(ip(open), Some("t"), false).is_ok(),
672                "{open}"
673            );
674            assert!(refuse_open_bind(ip(open), None, true).is_ok(), "{open}");
675        }
676    }
677
678    #[test]
679    fn loopback_is_unchanged() {
680        for lo in ["127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"] {
681            assert!(refuse_open_bind(ip(lo), None, false).is_ok(), "{lo}");
682        }
683    }
684}