Gate scoped BTCPay connect to sandbox + non-mainnet
Slices 3-4 of agent-payment-connect: a scoped key carrying the a-la-carte payment_providers:write scope may connect a BTCPay provider, but only on a sandbox daemon (KEYSAT_SANDBOX_MODE) and only for a non-mainnet (regtest/testnet/signet) store. Master may connect any network; disconnect and production/mainnet reconnect stay master-only. A credential that can repoint settlement is a fund-redirection key, so the gate is deliberately narrow and fails closed. - require_provider_connect: outer gate (sandbox flag) at start_connect - btcpay/network.rs classify_address_network + client::fetch_onchain_network: resolve the store network at finish_connect, fail-closed to mainnet on any ambiguity (no on-chain method, non-2xx, non-JSON, unknown prefix), before any webhook/persist side effect - initiator carried across the OAuth round-trip via btcpay_authorize_state (migration 0025: scoped_initiator + initiator_actor_hash); scoped connects are audited - the GET callback now returns the error's HTTP status (was a misleading 200 on a denied connect) - openapi.rs documents the BTCPay connect/callback/status/disconnect paths and the key-creation scopes field Validated end-to-end against a live regtest BTCPay. Full suite green; adds gate + network unit/integration tests.
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
-- Carry the connect *initiator* through the BTCPay OAuth round trip.
|
||||||
|
--
|
||||||
|
-- agent-payment-connect (plans/agent-payment-connect-scope.md): a scoped key
|
||||||
|
-- bearing `payment_providers:write` may start a BTCPay connect, but only on a
|
||||||
|
-- sandbox daemon (outer gate) AND only for a non-mainnet store (inner gate).
|
||||||
|
-- The inner gate can only be evaluated at callback time — that's the first
|
||||||
|
-- moment we know the store and can resolve its network. So the connect handler
|
||||||
|
-- must remember, across the operator's browser round-trip to BTCPay, whether
|
||||||
|
-- the initiator was the master key (may connect any network) or a scoped key
|
||||||
|
-- (restricted to non-mainnet).
|
||||||
|
--
|
||||||
|
-- `scoped_initiator`: 0 = master (no network restriction), 1 = scoped key
|
||||||
|
-- (callback enforces non-mainnet, fail-closed). Default 0 keeps any in-flight
|
||||||
|
-- pre-upgrade state token behaving as a master connect (the only kind that
|
||||||
|
-- existed before this migration).
|
||||||
|
-- `initiator_actor_hash`: sha256 of the initiating credential, so the callback
|
||||||
|
-- can write an audit row attributing the scoped connect without a header.
|
||||||
|
--
|
||||||
|
-- Additive, one-way (consistent with 0020-0022). The table is also pruned by
|
||||||
|
-- timestamp, so any pre-migration rows expire within 30 minutes regardless.
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
ALTER TABLE btcpay_authorize_state
|
||||||
|
ADD COLUMN scoped_initiator INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE btcpay_authorize_state
|
||||||
|
ADD COLUMN initiator_actor_hash TEXT;
|
||||||
@@ -227,6 +227,91 @@ pub async fn require_scope(
|
|||||||
Ok(token_hash)
|
Ok(token_hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Who initiated a payment-provider connect — determines the network gate at
|
||||||
|
/// callback time (`btcpay_authorize::finish_connect`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ConnectInitiator {
|
||||||
|
/// The master `admin_api_key`. May connect any network.
|
||||||
|
Master,
|
||||||
|
/// A scoped key carrying `payment_providers:write` on a sandbox daemon.
|
||||||
|
/// Restricted to non-mainnet stores (enforced after the OAuth round-trip,
|
||||||
|
/// once the store + network are known).
|
||||||
|
Scoped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gate for **starting** a BTCPay provider connect — the fund-redirection-
|
||||||
|
/// sensitive operation. Stricter than `require_scope`: a scoped key reaches it
|
||||||
|
/// ONLY with the à-la-carte `payment_providers:write` scope AND only on a
|
||||||
|
/// **sandbox daemon** (the OUTER gate — on a production box scoped connect is
|
||||||
|
/// disabled entirely, even for regtest, since a scoped key re-pointing
|
||||||
|
/// settlement on a live box is denial-of-revenue). The INNER gate (target
|
||||||
|
/// network must be non-mainnet) is enforced separately at callback time, once
|
||||||
|
/// the store is known. See `plans/agent-payment-connect-scope.md` §5.
|
||||||
|
///
|
||||||
|
/// Returns `(actor_hash, initiator)`. The caller records `initiator` in the
|
||||||
|
/// authorize-state row so the callback can apply the network gate. Master keys
|
||||||
|
/// bypass both gates (still subject to BTCPay's own OAuth approval).
|
||||||
|
pub async fn require_provider_connect(
|
||||||
|
state: &AppState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> AppResult<(String, ConnectInitiator)> {
|
||||||
|
let header_val = headers
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
let token = header_val
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
// Master admin key — full bypass, may connect any network.
|
||||||
|
if bool::from(
|
||||||
|
token
|
||||||
|
.as_bytes()
|
||||||
|
.ct_eq(state.config.admin_api_key.as_bytes()),
|
||||||
|
) {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
return Ok((hex::encode(hasher.finalize()), ConnectInitiator::Master));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped key — must carry `payment_providers:write` (never role-granted;
|
||||||
|
// only via à-la-carte `extra_scopes`) AND the daemon must be in sandbox mode.
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
let token_hash = hex::encode(hasher.finalize());
|
||||||
|
|
||||||
|
let row: Option<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT id, role, revoked_at, extra_scopes FROM scoped_api_keys WHERE token_hash = ?",
|
||||||
|
)
|
||||||
|
.bind(&token_hash)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (key_id, role_str, revoked_at, extra_scopes_json) = row.ok_or(AppError::Forbidden)?;
|
||||||
|
if revoked_at.is_some() {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
let role = Role::parse(&role_str).ok_or(AppError::Forbidden)?;
|
||||||
|
let has_scope = role.grants("payment_providers:write")
|
||||||
|
|| extra_scopes_contains(extra_scopes_json.as_deref(), "payment_providers:write");
|
||||||
|
if !has_scope {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
// OUTER gate: scoped connect is permitted only on a sandbox daemon.
|
||||||
|
if !state.config.sandbox_mode {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let _ = sqlx::query("UPDATE scoped_api_keys SET last_used_at = ? WHERE id = ?")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&key_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok((token_hash, ConnectInitiator::Scoped))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- CRUD endpoints (gated on master admin only) ----------
|
// ---------- CRUD endpoints (gated on master admin only) ----------
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -22,9 +22,14 @@
|
|||||||
//! callback path uses the CSRF `state` token to tie a callback back to the
|
//! callback path uses the CSRF `state` token to tie a callback back to the
|
||||||
//! issuing operator session.
|
//! issuing operator session.
|
||||||
|
|
||||||
use crate::api::{admin::{require_admin, require_scope}, AppState};
|
use crate::api::{
|
||||||
|
admin::{require_admin, require_scope},
|
||||||
|
api_keys::{require_provider_connect, ConnectInitiator},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
use crate::btcpay::client::{self as btcpay_client, BtcpayClient};
|
use crate::btcpay::client::{self as btcpay_client, BtcpayClient};
|
||||||
use crate::btcpay::config as btcpay_cfg;
|
use crate::btcpay::config as btcpay_cfg;
|
||||||
|
use crate::btcpay::network::BitcoinNetwork;
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::payment::btcpay::BtcpayProvider;
|
use crate::payment::btcpay::BtcpayProvider;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -84,7 +89,12 @@ pub async fn start_connect(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Option<Json<StartConnectReq>>,
|
body: Option<Json<StartConnectReq>>,
|
||||||
) -> AppResult<Json<ConnectResp>> {
|
) -> AppResult<Json<ConnectResp>> {
|
||||||
require_admin(&state, &headers)?;
|
// Master key → connect any network. Scoped key with `payment_providers:write`
|
||||||
|
// → permitted ONLY on a sandbox daemon (outer gate); the non-mainnet inner
|
||||||
|
// gate is enforced at callback time once the store is known. See
|
||||||
|
// `plans/agent-payment-connect-scope.md` §5.
|
||||||
|
let (actor_hash, initiator) = require_provider_connect(&state, &headers).await?;
|
||||||
|
let scoped_initiator = matches!(initiator, ConnectInitiator::Scoped);
|
||||||
let req = body.map(|Json(b)| b).unwrap_or_default();
|
let req = body.map(|Json(b)| b).unwrap_or_default();
|
||||||
|
|
||||||
// Resolve the target merchant profile (defaulting to the default).
|
// Resolve the target merchant profile (defaulting to the default).
|
||||||
@@ -115,9 +125,17 @@ pub async fn start_connect(
|
|||||||
rand::thread_rng().fill_bytes(&mut raw);
|
rand::thread_rng().fill_bytes(&mut raw);
|
||||||
let state_token = BASE32_NOPAD.encode(&raw);
|
let state_token = BASE32_NOPAD.encode(&raw);
|
||||||
|
|
||||||
btcpay_cfg::record_authorize_state(&state.db, &state_token, Some(&profile.id))
|
btcpay_cfg::record_authorize_state(
|
||||||
.await
|
&state.db,
|
||||||
.map_err(AppError::Internal)?;
|
&state_token,
|
||||||
|
Some(&profile.id),
|
||||||
|
scoped_initiator,
|
||||||
|
// Only stored for scoped connects (the callback's audit row). Master
|
||||||
|
// connects are covered by the StartOS action audit trail.
|
||||||
|
scoped_initiator.then_some(actor_hash.as_str()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
|
||||||
// Construct the authorize URL per BTCPay's docs.
|
// Construct the authorize URL per BTCPay's docs.
|
||||||
// https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys
|
// https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys
|
||||||
@@ -226,11 +244,18 @@ pub async fn callback_get(
|
|||||||
Ok(()) => success_page(
|
Ok(()) => success_page(
|
||||||
"BTCPay connected successfully. You can close this tab and return to Keysat.",
|
"BTCPay connected successfully. You can close this tab and return to Keysat.",
|
||||||
),
|
),
|
||||||
Err(e) => Html(format!(
|
// Carry the error's HTTP status onto the HTML page so a denied connect
|
||||||
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
|
// (e.g. a scoped key targeting a mainnet store -> 400) surfaces as a
|
||||||
html_escape::encode_text(&e.to_string())
|
// non-2xx an agent can detect, not a misleading 200. Matches the POST
|
||||||
))
|
// callback, which propagates the status via `?`.
|
||||||
.into_response(),
|
Err(e) => (
|
||||||
|
e.status_code(),
|
||||||
|
Html(format!(
|
||||||
|
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
|
||||||
|
html_escape::encode_text(&e.to_string())
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,10 +327,10 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
|
|||||||
// Recovers the `merchant_profile_id` recorded when the operator
|
// Recovers the `merchant_profile_id` recorded when the operator
|
||||||
// kicked off the connect flow. NULL falls back to the default
|
// kicked off the connect flow. NULL falls back to the default
|
||||||
// profile (back-compat for state tokens from pre-0022 runs).
|
// profile (back-compat for state tokens from pre-0022 runs).
|
||||||
let recorded_profile_id = btcpay_cfg::consume_authorize_state(&state.db, state_token)
|
let auth_state = btcpay_cfg::consume_authorize_state(&state.db, state_token)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::Unauthorized)?;
|
.map_err(|_| AppError::Unauthorized)?;
|
||||||
let profile = match recorded_profile_id.as_deref() {
|
let profile = match auth_state.merchant_profile_id.as_deref() {
|
||||||
Some(id) => crate::merchant_profiles::get(&state.db, id)
|
Some(id) => crate::merchant_profiles::get(&state.db, id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::BadRequest(format!(
|
.ok_or_else(|| AppError::BadRequest(format!(
|
||||||
@@ -331,6 +356,43 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
|
|||||||
"The authorized API key has access to zero stores. Re-run connect and pick a store.".into()
|
"The authorized API key has access to zero stores. Re-run connect and pick a store.".into()
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
|
// INNER gate (scoped initiators only): the target store must settle on a
|
||||||
|
// non-mainnet network. This is the first point in the flow where we know
|
||||||
|
// the store, so detection happens here — BEFORE registering any webhook or
|
||||||
|
// persisting the provider. Fail closed: if the network can't be positively
|
||||||
|
// determined as non-mainnet, treat it as mainnet and refuse. Master
|
||||||
|
// initiators skip this entirely (they may connect any network).
|
||||||
|
let resolved_network = if auth_state.scoped_initiator {
|
||||||
|
let network = match btcpay_client::fetch_onchain_network(base_url, api_key, &store.id).await {
|
||||||
|
Ok(Some(net)) => net,
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::warn!(
|
||||||
|
store = %store.id,
|
||||||
|
"scoped BTCPay connect: on-chain network undetermined → fail-closed to mainnet (deny)"
|
||||||
|
);
|
||||||
|
BitcoinNetwork::Mainnet
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
store = %store.id, error = %format!("{e:#}"),
|
||||||
|
"scoped BTCPay connect: network detection errored → fail-closed to mainnet (deny)"
|
||||||
|
);
|
||||||
|
BitcoinNetwork::Mainnet
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if network.is_mainnet() {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Scoped payment-provider connect is restricted to non-mainnet \
|
||||||
|
(regtest/testnet/signet) BTCPay stores; the selected store resolved \
|
||||||
|
to '{}'. Use the master admin key to connect a mainnet store.",
|
||||||
|
network.as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Some(network)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Generate a strong webhook secret, then register the webhook on BTCPay.
|
// Generate a strong webhook secret, then register the webhook on BTCPay.
|
||||||
let mut raw_secret = [0u8; 32];
|
let mut raw_secret = [0u8; 32];
|
||||||
rand::thread_rng().fill_bytes(&mut raw_secret);
|
rand::thread_rng().fill_bytes(&mut raw_secret);
|
||||||
@@ -387,14 +449,40 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
|
|||||||
state.set_payment_provider(provider).await;
|
state.set_payment_provider(provider).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let network_str = resolved_network.map(|n| n.as_str());
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
provider_id = %provider_id,
|
provider_id = %provider_id,
|
||||||
merchant_profile_id = %profile.id,
|
merchant_profile_id = %profile.id,
|
||||||
store = %store.id,
|
store = %store.id,
|
||||||
store_name = %store.name,
|
store_name = %store.name,
|
||||||
webhook_id = %created_webhook.id,
|
webhook_id = %created_webhook.id,
|
||||||
|
scoped = auth_state.scoped_initiator,
|
||||||
|
network = network_str.unwrap_or("master/any"),
|
||||||
"BTCPay connected via authorize flow"
|
"BTCPay connected via authorize flow"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Audit every scoped connect (spec §7) — attributes the fund-redirection-
|
||||||
|
// sensitive op to the initiating credential + the resolved network. Master
|
||||||
|
// connects are already covered by the StartOS action audit trail.
|
||||||
|
if auth_state.scoped_initiator {
|
||||||
|
let _ = crate::db::repo::insert_audit(
|
||||||
|
&state.db,
|
||||||
|
"scoped_api_key",
|
||||||
|
auth_state.initiator_actor_hash.as_deref(),
|
||||||
|
"payment_provider.connect_scoped",
|
||||||
|
Some("payment_provider"),
|
||||||
|
Some(&provider_id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&json!({
|
||||||
|
"kind": "btcpay",
|
||||||
|
"store_id": store.id,
|
||||||
|
"merchant_profile_id": profile.id,
|
||||||
|
"network": network_str,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -400,7 +400,8 @@ const SPEC_JSON: &str = r##"{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"label": { "type": "string", "description": "Operator-friendly name, e.g. 'Recap support bot'" },
|
"label": { "type": "string", "description": "Operator-friendly name, e.g. 'Recap support bot'" },
|
||||||
"role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "merchant-onboard", "full-admin"] }
|
"role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "merchant-onboard", "full-admin"] },
|
||||||
|
"scopes": { "type": "array", "items": { "type": "string", "enum": ["payment_providers:write"] }, "description": "A-la-carte extra scopes granted on top of the role. Only payment_providers:write today: lets the key connect a non-mainnet BTCPay provider on a sandbox daemon. In no role by default." }
|
||||||
},
|
},
|
||||||
"required": ["label", "role"]
|
"required": ["label", "role"]
|
||||||
} } }
|
} } }
|
||||||
@@ -418,9 +419,44 @@ const SPEC_JSON: &str = r##"{
|
|||||||
"/v1/admin/tier": {
|
"/v1/admin/tier": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Get this daemon's tier + usage + caps",
|
"summary": "Get this daemon's tier + usage + caps",
|
||||||
"description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier.",
|
"description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier. Includes a read-only `sandbox` boolean (true when KEYSAT_SANDBOX_MODE is set).",
|
||||||
"responses": { "200": { "description": "Tier info" } }
|
"responses": { "200": { "description": "Tier info" } }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/v1/admin/btcpay/connect": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Start a BTCPay provider connect",
|
||||||
|
"description": "Returns a one-time `state` token and the BTCPay authorize URL; complete the connect at /v1/btcpay/authorize/callback. The master key may connect any network. A scoped key needs the `payment_providers:write` extra scope AND a sandbox daemon (KEYSAT_SANDBOX_MODE); the target store must resolve to a non-mainnet network or the callback refuses. Optional JSON body: { merchant_profile_id }.",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "{ authorize_url, state, merchant_profile_id }" },
|
||||||
|
"403": { "description": "Scoped key without payment_providers:write, or not a sandbox daemon" },
|
||||||
|
"409": { "description": "Profile already has a BTCPay provider; disconnect first" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/btcpay/authorize/callback": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Complete a BTCPay connect",
|
||||||
|
"description": "BTCPay redirects here after the operator approves in a browser, or an agent calls it directly with a pre-issued store API key. Query params: `state` (from /connect) and `apiKey` (a BTCPay store key with the same store-settings + invoice permissions the browser flow grants). Keysat resolves the store's network and, for a scoped initiator, refuses anything not provably non-mainnet (fail-closed). No auth header; the single-use `state` token is the tie. A refusal returns a 4xx on both the GET and POST forms.",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Connected (HTML confirmation page)" },
|
||||||
|
"400": { "description": "Scoped connect to a mainnet/undetermined store; nothing persisted" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/admin/btcpay/status": {
|
||||||
|
"get": {
|
||||||
|
"summary": "BTCPay connection status (default profile)",
|
||||||
|
"description": "Requires payment_providers:read. Returns { connected, store_id, base_url, webhook_id, ... }.",
|
||||||
|
"responses": { "200": { "description": "Connection status" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/admin/btcpay/disconnect": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Disconnect a BTCPay provider",
|
||||||
|
"description": "Master admin key required, on any daemon. Best-effort revokes the webhook + key on BTCPay, then clears the local provider row.",
|
||||||
|
"responses": { "200": { "description": "Disconnected (or no-op)" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}"##;
|
}"##;
|
||||||
|
|||||||
@@ -366,3 +366,87 @@ pub async fn list_payment_methods(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the Bitcoin **network** a store settles on, for the scoped
|
||||||
|
/// payment-connect gate (`plans/agent-payment-connect-scope.md` §6.1).
|
||||||
|
///
|
||||||
|
/// Lists the store's payment methods, finds the on-chain BTC method
|
||||||
|
/// (`paymentMethodId` is `BTC-CHAIN` on BTCPay 2.x, `BTC` on 1.x — never
|
||||||
|
/// hardcode), fetches a receive address, and classifies the address prefix.
|
||||||
|
///
|
||||||
|
/// Returns:
|
||||||
|
/// - `Ok(Some(network))` when positively determined;
|
||||||
|
/// - `Ok(None)` when it **cannot** be determined (no on-chain method, no
|
||||||
|
/// address, Lightning-only store, BTCPay not yet synced → `503`, or an
|
||||||
|
/// unrecognized prefix). The caller MUST fail closed (treat `None` as
|
||||||
|
/// mainnet and deny the scoped connect).
|
||||||
|
///
|
||||||
|
/// The address endpoint requires `btcpay.store.canmodifystoresettings`, which
|
||||||
|
/// the daemon's authorize flow already requests (see `REQUESTED_PERMISSIONS`).
|
||||||
|
pub async fn fetch_onchain_network(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
store_id: &str,
|
||||||
|
) -> Result<Option<super::network::BitcoinNetwork>> {
|
||||||
|
// Any failure to enumerate methods → undetermined → caller fails closed.
|
||||||
|
// Swallow the error here (uniform with the non-2xx wallet/address branch
|
||||||
|
// below) and log a body-free reason at warn; detail only at debug so an
|
||||||
|
// upstream error body never lands in normal logs on this sensitive path.
|
||||||
|
let methods = match list_payment_methods(base_url, api_key, store_id).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
store = %store_id,
|
||||||
|
"fetch_onchain_network: could not list payment methods; network undetermined"
|
||||||
|
);
|
||||||
|
tracing::debug!(error = %format!("{e:#}"), "btcpay list-payment-methods error detail");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Find the on-chain BTC method. Lightning ids (`BTC-LN`,
|
||||||
|
// `BTC_LightningLike`, …) are deliberately excluded.
|
||||||
|
let Some(pmid) = methods.iter().find_map(|m| {
|
||||||
|
let id = m.get("paymentMethodId").and_then(|v| v.as_str())?;
|
||||||
|
match id.to_ascii_uppercase().as_str() {
|
||||||
|
"BTC-CHAIN" | "BTC" => Some(id.to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}) else {
|
||||||
|
return Ok(None); // no on-chain BTC method → undetermined → fail closed
|
||||||
|
};
|
||||||
|
|
||||||
|
// `pmid` is BTCPay-supplied; percent-encode it as a path segment so a
|
||||||
|
// hostile/buggy server returning an odd id can't corrupt the URL (it would
|
||||||
|
// only ever 4xx → Ok(None) → deny anyway, but keep the request well-formed).
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/stores/{store_id}/payment-methods/{}/wallet/address",
|
||||||
|
base_url.trim_end_matches('/'),
|
||||||
|
urlencoding::encode(&pmid),
|
||||||
|
);
|
||||||
|
let resp = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()?
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("token {api_key}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("calling BTCPay wallet/address")?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
// 503 (BTCPay not synced / on-chain service down), 404/422 (no wallet),
|
||||||
|
// 403 (insufficient perms) — none let us positively determine the
|
||||||
|
// network, so report undetermined and let the caller fail closed.
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// A 2xx with a non-JSON body (misconfigured BTCPay) is likewise "can't
|
||||||
|
// determine" → Ok(None). Parsing via Ok(None) instead of `?` also keeps any
|
||||||
|
// body snippet reqwest attaches to a parse error out of warn-level logs.
|
||||||
|
let body: serde_json::Value = match resp.json().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(error = %format!("{e:#}"), "btcpay wallet/address: non-JSON body; network undetermined");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let address = body.get("address").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
Ok(super::network::classify_address_network(address))
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,23 +79,47 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An in-flight authorize round-trip, recovered at callback time. `Default`
|
||||||
|
/// (no profile, `scoped_initiator = false`) is the back-compat reading of a
|
||||||
|
/// pre-0025 / NULL row: "master connect to the default profile" — the only
|
||||||
|
/// kind that existed before scoped connect.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AuthorizeState {
|
||||||
|
/// Merchant profile the resulting provider row attaches to (migration
|
||||||
|
/// 0022). None → "the default profile".
|
||||||
|
pub merchant_profile_id: Option<String>,
|
||||||
|
/// True when a *scoped* key (not the master key) started the connect
|
||||||
|
/// (migration 0025). The callback applies the non-mainnet network gate
|
||||||
|
/// only for scoped initiators.
|
||||||
|
pub scoped_initiator: bool,
|
||||||
|
/// sha256 of the initiating credential — for the callback's audit row.
|
||||||
|
pub initiator_actor_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a new in-flight authorize state token. `merchant_profile_id`
|
/// Record a new in-flight authorize state token. `merchant_profile_id`
|
||||||
/// (multi-provider model, migration 0022) names which merchant profile
|
/// (multi-provider model, migration 0022) names which merchant profile
|
||||||
/// the resulting provider row should attach to when the callback fires
|
/// the resulting provider row should attach to when the callback fires
|
||||||
/// — None falls back to "the default profile" at consume-time.
|
/// — None falls back to "the default profile" at consume-time.
|
||||||
|
/// `scoped_initiator` / `actor_hash` (migration 0025) carry who started the
|
||||||
|
/// connect so the callback can apply the network gate + attribute the audit.
|
||||||
pub async fn record_authorize_state(
|
pub async fn record_authorize_state(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
token: &str,
|
token: &str,
|
||||||
merchant_profile_id: Option<&str>,
|
merchant_profile_id: Option<&str>,
|
||||||
|
scoped_initiator: bool,
|
||||||
|
actor_hash: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO btcpay_authorize_state (state_token, merchant_profile_id, created_at) \
|
"INSERT INTO btcpay_authorize_state \
|
||||||
VALUES (?, ?, ?)",
|
(state_token, merchant_profile_id, created_at, scoped_initiator, initiator_actor_hash) \
|
||||||
|
VALUES (?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(token)
|
.bind(token)
|
||||||
.bind(merchant_profile_id)
|
.bind(merchant_profile_id)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
|
.bind(scoped_initiator as i64)
|
||||||
|
.bind(actor_hash)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.context("recording btcpay authorize state")?;
|
.context("recording btcpay authorize state")?;
|
||||||
@@ -109,17 +133,18 @@ pub async fn record_authorize_state(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Validate that `token` was issued recently and has not been consumed.
|
/// Validate that `token` was issued recently and has not been consumed.
|
||||||
/// Consumes (deletes) the token on success so a replay fails, and
|
/// Consumes (deletes) the token on success so a replay fails, and returns the
|
||||||
/// returns the `merchant_profile_id` recorded at start-connect time so
|
/// recorded `AuthorizeState` (profile + initiator) so the callback knows which
|
||||||
/// the callback knows which profile to attach the new provider to.
|
/// profile to attach to and whether to apply the scoped network gate.
|
||||||
pub async fn consume_authorize_state(
|
pub async fn consume_authorize_state(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<AuthorizeState> {
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT state_token, merchant_profile_id FROM btcpay_authorize_state \
|
"SELECT merchant_profile_id, scoped_initiator, initiator_actor_hash \
|
||||||
|
FROM btcpay_authorize_state \
|
||||||
WHERE state_token = ? AND created_at >= ?",
|
WHERE state_token = ? AND created_at >= ?",
|
||||||
)
|
)
|
||||||
.bind(token)
|
.bind(token)
|
||||||
@@ -130,11 +155,19 @@ pub async fn consume_authorize_state(
|
|||||||
let Some(row) = row else {
|
let Some(row) = row else {
|
||||||
return Err(anyhow!("unknown or expired authorize state token"));
|
return Err(anyhow!("unknown or expired authorize state token"));
|
||||||
};
|
};
|
||||||
let merchant_profile_id: Option<String> = row.try_get("merchant_profile_id").ok().flatten();
|
let state = AuthorizeState {
|
||||||
|
merchant_profile_id: row.try_get("merchant_profile_id").ok().flatten(),
|
||||||
|
// Tolerant read: a NULL/absent column reads as 0 (master) — fail toward
|
||||||
|
// the *less*-restrictive master path is acceptable here because the
|
||||||
|
// column only exists to ADD the scoped restriction; a pre-0025 token
|
||||||
|
// could only ever have been a master connect.
|
||||||
|
scoped_initiator: row.try_get::<i64, _>("scoped_initiator").unwrap_or(0) != 0,
|
||||||
|
initiator_actor_hash: row.try_get("initiator_actor_hash").ok().flatten(),
|
||||||
|
};
|
||||||
|
|
||||||
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
|
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
|
||||||
.bind(token)
|
.bind(token)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(merchant_profile_id)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@
|
|||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod network;
|
||||||
pub mod webhook;
|
pub mod webhook;
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! Bitcoin network classification from an address string.
|
||||||
|
//!
|
||||||
|
//! Used by the agent-payment-connect gate (`plans/agent-payment-connect-scope.md`
|
||||||
|
//! §6.1): a *scoped* key may connect a BTCPay store only when its target network
|
||||||
|
//! is non-mainnet. Greenfield's `GET /api/v1/server/info` carries no chain-type
|
||||||
|
//! field, so we determine the network from a **network-encoding artifact** — the
|
||||||
|
//! store's on-chain receive address — and classify by its prefix.
|
||||||
|
//!
|
||||||
|
//! Validated against a live regtest BTCPay 2.x: `wallet/address` returns a
|
||||||
|
//! `bcrt1…` address on regtest (see `onboarding-harness/stage2/btcpay-regtest/`).
|
||||||
|
//!
|
||||||
|
//! **Fail-closed:** an unrecognized / empty address yields `None`; the caller
|
||||||
|
//! MUST treat `None` as mainnet (deny the scoped connect). Never assume
|
||||||
|
//! non-mainnet from absence of evidence.
|
||||||
|
|
||||||
|
/// The Bitcoin network a BTCPay store settles on. Only the mainnet-vs-rest
|
||||||
|
/// distinction gates the scoped connect, but the specific non-mainnet variant
|
||||||
|
/// is kept for audit/logging.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BitcoinNetwork {
|
||||||
|
Mainnet,
|
||||||
|
/// testnet3 — shares the `tb1` HRP and `m`/`n`/`2` base58 versions with signet.
|
||||||
|
Testnet,
|
||||||
|
/// Signet — indistinguishable from testnet by address alone (`tb1`), so the
|
||||||
|
/// address classifier never yields this; reserved for a future
|
||||||
|
/// derivation-scheme-based path. Kept distinct because it is a real,
|
||||||
|
/// non-mainnet network the gate must allow.
|
||||||
|
Signet,
|
||||||
|
Regtest,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BitcoinNetwork {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
BitcoinNetwork::Mainnet => "mainnet",
|
||||||
|
BitcoinNetwork::Testnet => "testnet",
|
||||||
|
BitcoinNetwork::Signet => "signet",
|
||||||
|
BitcoinNetwork::Regtest => "regtest",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The only question the connect gate actually asks.
|
||||||
|
pub fn is_mainnet(self) -> bool {
|
||||||
|
matches!(self, BitcoinNetwork::Mainnet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a Bitcoin address by its network-encoding prefix. Returns `None`
|
||||||
|
/// when the prefix is unrecognized or the string is empty — the caller
|
||||||
|
/// **fails closed** (treats `None` as mainnet).
|
||||||
|
///
|
||||||
|
/// bech32/bech32m HRP: `bcrt1…`=regtest, `tb1…`=testnet/signet, `bc1…`=mainnet.
|
||||||
|
/// Legacy base58: `1`/`3`=mainnet, `m`/`n`/`2`=test/regtest (the `tb1`/base58
|
||||||
|
/// test versions are shared by testnet, signet, and regtest — all non-mainnet,
|
||||||
|
/// which is all the gate needs; only the bech32 `bcrt1` HRP pins regtest
|
||||||
|
/// specifically).
|
||||||
|
pub fn classify_address_network(addr: &str) -> Option<BitcoinNetwork> {
|
||||||
|
let s = addr.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// bech32/bech32m — HRP is case-insensitive. Check `bcrt1` before `bc1`
|
||||||
|
// (it is not a prefix of the others, but order makes the intent explicit).
|
||||||
|
let lower = s.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("bcrt1") {
|
||||||
|
return Some(BitcoinNetwork::Regtest);
|
||||||
|
}
|
||||||
|
if lower.starts_with("tb1") {
|
||||||
|
// testnet and signet share the `tb` HRP and are indistinguishable from
|
||||||
|
// the address alone. Both non-mainnet; report Testnet.
|
||||||
|
return Some(BitcoinNetwork::Testnet);
|
||||||
|
}
|
||||||
|
if lower.starts_with("bc1") {
|
||||||
|
return Some(BitcoinNetwork::Mainnet);
|
||||||
|
}
|
||||||
|
// Legacy base58check — version byte encoded in the leading character.
|
||||||
|
// Only classify when the whole string is a *plausible* base58 address
|
||||||
|
// (correct alphabet + length): otherwise arbitrary text that merely begins
|
||||||
|
// with `n`/`m`/`2` (e.g. "not-an-address") would be mis-read as non-mainnet
|
||||||
|
// and the gate would fail OPEN. Junk falls through to `None` (fail closed).
|
||||||
|
// Case-sensitive, so classify off the original string.
|
||||||
|
if (26..=35).contains(&s.len()) && s.chars().all(is_base58) {
|
||||||
|
return match s.chars().next() {
|
||||||
|
Some('1') | Some('3') => Some(BitcoinNetwork::Mainnet),
|
||||||
|
Some('m') | Some('n') | Some('2') => Some(BitcoinNetwork::Testnet),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base58 alphabet membership (Bitcoin's: omits `0`, `O`, `I`, `l`).
|
||||||
|
fn is_base58(c: char) -> bool {
|
||||||
|
matches!(c, '1'..='9' | 'A'..='H' | 'J'..='N' | 'P'..='Z' | 'a'..='k' | 'm'..='z')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bech32_prefixes() {
|
||||||
|
// The exact address the live regtest BTCPay 2.x returned.
|
||||||
|
assert_eq!(
|
||||||
|
classify_address_network("bcrt1qwsh9ua5qeutshvrhz474uduwqlw8gfukfpc8vt"),
|
||||||
|
Some(BitcoinNetwork::Regtest)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_address_network("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"),
|
||||||
|
Some(BitcoinNetwork::Testnet)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_address_network("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"),
|
||||||
|
Some(BitcoinNetwork::Mainnet)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bech32_is_case_insensitive() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_address_network("BCRT1QWSH9UA5QEUTSHVRHZ474UDUWQLW8GFUKFPC8VT"),
|
||||||
|
Some(BitcoinNetwork::Regtest)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_address_network("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4"),
|
||||||
|
Some(BitcoinNetwork::Mainnet)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_base58() {
|
||||||
|
assert_eq!(classify_address_network("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"), Some(BitcoinNetwork::Mainnet)); // P2PKH
|
||||||
|
assert_eq!(classify_address_network("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), Some(BitcoinNetwork::Mainnet)); // P2SH
|
||||||
|
assert_eq!(classify_address_network("mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"), Some(BitcoinNetwork::Testnet)); // testnet P2PKH
|
||||||
|
assert_eq!(classify_address_network("n2ZNV88uQbede7C5M5jzi6SyG4GVuPpng6"), Some(BitcoinNetwork::Testnet));
|
||||||
|
assert_eq!(classify_address_network("2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc"), Some(BitcoinNetwork::Testnet)); // test P2SH
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fail_closed_on_unknown_or_empty() {
|
||||||
|
assert_eq!(classify_address_network(""), None);
|
||||||
|
assert_eq!(classify_address_network(" "), None);
|
||||||
|
assert_eq!(classify_address_network("not-an-address"), None);
|
||||||
|
assert_eq!(classify_address_network("ltc1qxyz"), None); // not bitcoin
|
||||||
|
assert_eq!(classify_address_network("zzz"), None);
|
||||||
|
// The dangerous direction: a base58-length, all-base58 string that does
|
||||||
|
// NOT begin with a version char (1/3/m/n/2) must stay None, never be
|
||||||
|
// mis-read as non-mainnet. (And a real mainnet address always begins
|
||||||
|
// with 1/3/bc1, so it can never fall into the non-mainnet arms.)
|
||||||
|
assert_eq!(classify_address_network("bQ8vZ2mN4pR7sT1uW3xY5zA6dE9fG"), None); // 29 chars, starts 'b'
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_mainnet_only_true_for_mainnet() {
|
||||||
|
assert!(BitcoinNetwork::Mainnet.is_mainnet());
|
||||||
|
assert!(!BitcoinNetwork::Testnet.is_mainnet());
|
||||||
|
assert!(!BitcoinNetwork::Signet.is_mainnet());
|
||||||
|
assert!(!BitcoinNetwork::Regtest.is_mainnet());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,23 +59,46 @@ pub enum AppError {
|
|||||||
Internal(#[from] anyhow::Error),
|
Internal(#[from] anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AppError {
|
||||||
|
/// HTTP status this error maps to. Exposed so handlers that render a
|
||||||
|
/// non-JSON body (e.g. the BTCPay callback's HTML page) still return the
|
||||||
|
/// correct status instead of a misleading 200 on a denied request.
|
||||||
|
pub fn status_code(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
|
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
|
AppError::Forbidden => StatusCode::FORBIDDEN,
|
||||||
|
AppError::Conflict(_) => StatusCode::CONFLICT,
|
||||||
|
AppError::LicenseInvalid(_) => StatusCode::OK,
|
||||||
|
AppError::Upstream(_) => StatusCode::BAD_GATEWAY,
|
||||||
|
AppError::BtcpayNotConfigured => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
AppError::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
AppError::PaymentRequired { .. } => StatusCode::PAYMENT_REQUIRED,
|
||||||
|
AppError::Database(_) | AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, code) = match &self {
|
let status = self.status_code();
|
||||||
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
let code = match &self {
|
||||||
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
AppError::NotFound(_) => "not_found",
|
||||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
AppError::BadRequest(_) => "bad_request",
|
||||||
AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
AppError::Unauthorized => "unauthorized",
|
||||||
AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
AppError::Forbidden => "forbidden",
|
||||||
AppError::LicenseInvalid(_) => (StatusCode::OK, "invalid"),
|
AppError::Conflict(_) => "conflict",
|
||||||
AppError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream_error"),
|
AppError::LicenseInvalid(_) => "invalid",
|
||||||
AppError::BtcpayNotConfigured => (StatusCode::SERVICE_UNAVAILABLE, "btcpay_not_configured"),
|
AppError::Upstream(_) => "upstream_error",
|
||||||
AppError::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"),
|
AppError::BtcpayNotConfigured => "btcpay_not_configured",
|
||||||
AppError::ServiceUnavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable"),
|
AppError::TooManyRequests(_) => "rate_limited",
|
||||||
AppError::PaymentRequired { .. } => (StatusCode::PAYMENT_REQUIRED, "tier_cap"),
|
AppError::ServiceUnavailable(_) => "service_unavailable",
|
||||||
|
AppError::PaymentRequired { .. } => "tier_cap",
|
||||||
AppError::Database(_) | AppError::Internal(_) => {
|
AppError::Database(_) | AppError::Internal(_) => {
|
||||||
tracing::error!(error = %self, "internal error");
|
tracing::error!(error = %self, "internal error");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
"internal_error"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ async fn make_pool() -> (SqlitePool, NamedTempFile) {
|
|||||||
/// - `self_tier = Tier::Unlicensed` inherits Creator-tier caps (5
|
/// - `self_tier = Tier::Unlicensed` inherits Creator-tier caps (5
|
||||||
/// products, 5 codes, etc.). Plenty for the small fixtures here.
|
/// products, 5 codes, etc.). Plenty for the small fixtures here.
|
||||||
async fn make_test_state() -> (AppState, NamedTempFile) {
|
async fn make_test_state() -> (AppState, NamedTempFile) {
|
||||||
|
make_test_state_inner(false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same fixture but with the daemon sandbox flag ON — for the
|
||||||
|
/// agent-payment-connect outer gate (a scoped `payment_providers:write` key may
|
||||||
|
/// only start a connect on a sandbox daemon).
|
||||||
|
async fn make_test_state_sandbox() -> (AppState, NamedTempFile) {
|
||||||
|
make_test_state_inner(true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn make_test_state_inner(sandbox_mode: bool) -> (AppState, NamedTempFile) {
|
||||||
let (pool, tmp) = make_pool().await;
|
let (pool, tmp) = make_pool().await;
|
||||||
let keypair = crypto::keys::load_or_generate(&pool)
|
let keypair = crypto::keys::load_or_generate(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -103,7 +114,7 @@ async fn make_test_state() -> (AppState, NamedTempFile) {
|
|||||||
btcpay_webhook_secret: None,
|
btcpay_webhook_secret: None,
|
||||||
public_base_url: "http://keysat.test".to_string(),
|
public_base_url: "http://keysat.test".to_string(),
|
||||||
operator_name: Some("Test Operator".into()),
|
operator_name: Some("Test Operator".into()),
|
||||||
sandbox_mode: false,
|
sandbox_mode,
|
||||||
};
|
};
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -3607,6 +3618,187 @@ async fn scoped_key_create_rejects_ungrantable_scope() {
|
|||||||
assert_eq!(send(&state, req).await.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(send(&state, req).await.status(), StatusCode::BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mint a scoped key of `role` plus à-la-carte `scopes`, returning its token.
|
||||||
|
async fn mint_scoped_key_with_scopes(state: &AppState, role: &str, scopes: &[&str]) -> String {
|
||||||
|
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
"/v1/admin/api-keys",
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({ "label": format!("{role}+scopes key"), "role": role, "scopes": scopes })),
|
||||||
|
);
|
||||||
|
let resp = send(state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK, "minting {role}+{scopes:?} should succeed");
|
||||||
|
body_json(resp)
|
||||||
|
.await
|
||||||
|
.get("token")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.expect("create returns the raw token once")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- agent-payment-connect gate (slices 3-4) -----
|
||||||
|
// `plans/agent-payment-connect-scope.md`: a scoped `payment_providers:write`
|
||||||
|
// key may START a BTCPay connect ONLY on a sandbox daemon (outer gate); the
|
||||||
|
// non-mainnet inner gate is enforced at callback time (covered live in
|
||||||
|
// tests/btcpay_network_live.rs). These cover the HTTP-level outer gate.
|
||||||
|
|
||||||
|
/// OUTER gate, production: a scoped `payment_providers:write` key is 403 on a
|
||||||
|
/// non-sandbox daemon — even though it holds the scope. Proves §5.1 (a scoped
|
||||||
|
/// key cannot repoint settlement on a live box, regtest or otherwise).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn payment_connect_outer_gate_denies_scoped_on_production() {
|
||||||
|
let (state, _tmp) = make_test_state().await; // sandbox_mode = false
|
||||||
|
let token =
|
||||||
|
mint_scoped_key_with_scopes(&state, "merchant-onboard", &["payment_providers:write"]).await;
|
||||||
|
let auth = format!("Bearer {token}");
|
||||||
|
let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None);
|
||||||
|
assert_eq!(
|
||||||
|
send(&state, req).await.status(),
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"scoped payment_providers:write key must be 403 on a non-sandbox daemon"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OUTER gate, sandbox: the same key passes on a sandbox daemon, and the
|
||||||
|
/// connect is recorded as a SCOPED initiator so the callback applies the
|
||||||
|
/// non-mainnet network gate.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn payment_connect_outer_gate_allows_scoped_on_sandbox() {
|
||||||
|
let (state, _tmp) = make_test_state_sandbox().await; // sandbox_mode = true
|
||||||
|
let token =
|
||||||
|
mint_scoped_key_with_scopes(&state, "merchant-onboard", &["payment_providers:write"]).await;
|
||||||
|
let auth = format!("Bearer {token}");
|
||||||
|
let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK, "scoped key passes the outer gate on sandbox");
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert!(
|
||||||
|
body["authorize_url"].as_str().unwrap_or("").contains("/api-keys/authorize"),
|
||||||
|
"returns a BTCPay authorize URL; got {body:?}"
|
||||||
|
);
|
||||||
|
let (scoped, actor_hash): (i64, Option<String>) = sqlx::query_as(
|
||||||
|
"SELECT scoped_initiator, initiator_actor_hash FROM btcpay_authorize_state WHERE state_token = ?",
|
||||||
|
)
|
||||||
|
.bind(body["state"].as_str().expect("state token echoed"))
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("authorize_state row persisted");
|
||||||
|
assert_eq!(scoped, 1, "the callback must see this as a scoped initiator");
|
||||||
|
assert!(
|
||||||
|
actor_hash.is_some(),
|
||||||
|
"the scoped initiator's actor hash must be recorded for the callback's audit row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scoped key WITHOUT `payment_providers:write` is 403 even on a sandbox
|
||||||
|
/// daemon — the scope is in no role (not even full-admin), so merchant-onboard
|
||||||
|
/// can't reach connect. Proves the gate isn't widened by role.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn payment_connect_denies_scoped_without_the_scope() {
|
||||||
|
let (state, _tmp) = make_test_state_sandbox().await; // sandbox ON, so only the missing scope can deny
|
||||||
|
let auth = format!("Bearer {}", mint_scoped_key(&state, "merchant-onboard").await);
|
||||||
|
let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None);
|
||||||
|
assert_eq!(
|
||||||
|
send(&state, req).await.status(),
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"merchant-onboard without payment_providers:write must be 403 (no role widening)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The master key may start a connect on ANY daemon (bypasses the sandbox
|
||||||
|
/// gate). Recorded as a master (non-scoped) initiator → callback applies no
|
||||||
|
/// network restriction.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn payment_connect_allows_master_on_production() {
|
||||||
|
let (state, _tmp) = make_test_state().await; // sandbox OFF
|
||||||
|
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
|
||||||
|
let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK, "master may connect on any daemon");
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
let scoped: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT scoped_initiator FROM btcpay_authorize_state WHERE state_token = ?",
|
||||||
|
)
|
||||||
|
.bind(body["state"].as_str().unwrap())
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("authorize_state row persisted");
|
||||||
|
assert_eq!(scoped, 0, "master connect is not a scoped initiator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The initiator + actor hash round-trip through `btcpay_authorize_state`
|
||||||
|
/// (migration 0025): recorded at start, recovered at callback, single-use.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorize_state_carries_scoped_initiator() {
|
||||||
|
let (state, _tmp) = make_test_state().await;
|
||||||
|
let profile = repo::get_default_merchant_profile(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("query default profile")
|
||||||
|
.expect("a default profile exists post-migration");
|
||||||
|
|
||||||
|
keysat::btcpay::config::record_authorize_state(
|
||||||
|
&state.db,
|
||||||
|
"tok_scoped",
|
||||||
|
Some(&profile.id),
|
||||||
|
true,
|
||||||
|
Some("deadbeef"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("record scoped");
|
||||||
|
let s = keysat::btcpay::config::consume_authorize_state(&state.db, "tok_scoped")
|
||||||
|
.await
|
||||||
|
.expect("consume scoped");
|
||||||
|
assert!(s.scoped_initiator, "scoped_initiator must round-trip");
|
||||||
|
assert_eq!(s.initiator_actor_hash.as_deref(), Some("deadbeef"));
|
||||||
|
assert_eq!(s.merchant_profile_id.as_deref(), Some(profile.id.as_str()));
|
||||||
|
// Single-use: a replay of the same token fails.
|
||||||
|
assert!(
|
||||||
|
keysat::btcpay::config::consume_authorize_state(&state.db, "tok_scoped")
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"consumed token must not replay"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Master initiator: defaults (not scoped, no hash).
|
||||||
|
keysat::btcpay::config::record_authorize_state(
|
||||||
|
&state.db,
|
||||||
|
"tok_master",
|
||||||
|
Some(&profile.id),
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("record master");
|
||||||
|
let m = keysat::btcpay::config::consume_authorize_state(&state.db, "tok_master")
|
||||||
|
.await
|
||||||
|
.expect("consume master");
|
||||||
|
assert!(!m.scoped_initiator);
|
||||||
|
assert_eq!(m.initiator_actor_hash, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The GET BTCPay callback must surface a failed/denied connect as a non-2xx
|
||||||
|
/// status, not a 200 with an HTML error body (the POST callback already does via
|
||||||
|
/// `?`). An unknown state token fails closed at consume time -> 401. This guards
|
||||||
|
/// the regression where the deny path (e.g. a scoped key targeting a mainnet
|
||||||
|
/// store) would otherwise return 200 with no programmatic error signal.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn btcpay_callback_get_propagates_error_status() {
|
||||||
|
let (state, _tmp) = make_test_state_sandbox().await;
|
||||||
|
let req = build_request(
|
||||||
|
"GET",
|
||||||
|
"/v1/btcpay/authorize/callback?state=bogus-token&apiKey=whatever",
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"a GET callback with an invalid state token must return 401, not a 200 error page"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Zaprite Connect refuses on Creator-tier (no `zaprite_payments`
|
/// Zaprite Connect refuses on Creator-tier (no `zaprite_payments`
|
||||||
/// entitlement) with 402. Switching the daemon's self-tier to a
|
/// entitlement) with 402. Switching the daemon's self-tier to a
|
||||||
/// Pro-flavored Licensed tier lets the Connect-precheck pass (it then
|
/// Pro-flavored Licensed tier lets the Connect-precheck pass (it then
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//! Live re-validation of the agent-payment-connect network detection against a
|
||||||
|
//! real BTCPay regtest box. Exercises the daemon's ACTUAL
|
||||||
|
//! `btcpay::client::fetch_onchain_network` (not a curl reimplementation), which
|
||||||
|
//! is what the scoped-connect gate calls at callback time.
|
||||||
|
//!
|
||||||
|
//! `#[ignore]` by default — it needs a running BTCPay regtest stack and reads
|
||||||
|
//! its connection params from the environment (no secrets in the tree). Bring
|
||||||
|
//! the box up and run:
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! cd ../onboarding-harness/stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d
|
||||||
|
//! # mint a canmodifystoresettings token + a store with an on-chain wallet, then:
|
||||||
|
//! source ../onboarding-harness/stage2/btcpay-regtest/.live-env
|
||||||
|
//! cargo test --test btcpay_network_live -- --ignored --nocapture
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Spec: `plans/agent-payment-connect-scope.md` §6.1 — "BTCPay on-chain address
|
||||||
|
//! network detection MUST be validated against a live regtest box."
|
||||||
|
|
||||||
|
use keysat::btcpay::client::fetch_onchain_network;
|
||||||
|
use keysat::btcpay::network::BitcoinNetwork;
|
||||||
|
|
||||||
|
fn env(key: &str) -> Option<String> {
|
||||||
|
std::env::var(key).ok().filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs a live BTCPay regtest box; set KEYSAT_LIVE_BTCPAY_* env"]
|
||||||
|
async fn regtest_store_resolves_to_regtest() {
|
||||||
|
let (Some(base), Some(key), Some(store)) = (
|
||||||
|
env("KEYSAT_LIVE_BTCPAY_URL"),
|
||||||
|
env("KEYSAT_LIVE_BTCPAY_KEY"),
|
||||||
|
env("KEYSAT_LIVE_BTCPAY_STORE_REGTEST"),
|
||||||
|
) else {
|
||||||
|
eprintln!("SKIP: set KEYSAT_LIVE_BTCPAY_URL / _KEY / _STORE_REGTEST");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let net = fetch_onchain_network(&base, &key, &store)
|
||||||
|
.await
|
||||||
|
.expect("detection call should not transport-error against a live box");
|
||||||
|
println!("regtest store {store} resolved to {net:?}");
|
||||||
|
assert_eq!(
|
||||||
|
net,
|
||||||
|
Some(BitcoinNetwork::Regtest),
|
||||||
|
"the on-chain wallet's bcrt1 address must classify as Regtest (non-mainnet → scoped connect allowed)"
|
||||||
|
);
|
||||||
|
assert!(!net.unwrap().is_mainnet(), "regtest must not be mainnet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs a live BTCPay regtest box; set KEYSAT_LIVE_BTCPAY_* env"]
|
||||||
|
async fn store_without_onchain_wallet_is_undetermined() {
|
||||||
|
let (Some(base), Some(key), Some(store)) = (
|
||||||
|
env("KEYSAT_LIVE_BTCPAY_URL"),
|
||||||
|
env("KEYSAT_LIVE_BTCPAY_KEY"),
|
||||||
|
env("KEYSAT_LIVE_BTCPAY_STORE_NOWALLET"),
|
||||||
|
) else {
|
||||||
|
eprintln!("SKIP: set KEYSAT_LIVE_BTCPAY_URL / _KEY / _STORE_NOWALLET");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let net = fetch_onchain_network(&base, &key, &store)
|
||||||
|
.await
|
||||||
|
.expect("detection call should not transport-error");
|
||||||
|
println!("no-wallet store {store} resolved to {net:?}");
|
||||||
|
// No on-chain wallet → undetermined → caller fails closed to mainnet → deny.
|
||||||
|
assert_eq!(
|
||||||
|
net, None,
|
||||||
|
"a store with no on-chain wallet must be undetermined so the gate fails closed"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user