Compare commits

...

2 Commits

Author SHA1 Message Date
Grant c673b10a94 Add Stage 2 onboarding harness (buyer pays on regtest)
Disposable rig that runs the onboarding-tester agent docs-only against the
buyer-pays journey: a sandbox daemon wired to a Dockerized BTCPay regtest stack,
a scoped key with payment_providers:write, and a regtest buyer-pay helper.
Includes the de-risk probe + findings and an end-to-end gate check
(validate-gate.sh, 10/10). The doc-onboarding loop converged completed-clean;
see stage2/STAGE2-RESULT.md. Scratch (.live-env, probe-out/) is gitignored.
2026-06-17 09:32:07 -05:00
Grant 8eb4a97c6f 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.
2026-06-17 09:31:57 -05:00
20 changed files with 1419 additions and 44 deletions
@@ -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;
+85
View File
@@ -227,6 +227,91 @@ pub async fn require_scope(
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) ----------
#[derive(Debug, Deserialize)]
+95 -7
View File
@@ -22,9 +22,14 @@
//! callback path uses the CSRF `state` token to tie a callback back to the
//! 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::config as btcpay_cfg;
use crate::btcpay::network::BitcoinNetwork;
use crate::error::{AppError, AppResult};
use crate::payment::btcpay::BtcpayProvider;
use std::sync::Arc;
@@ -84,7 +89,12 @@ pub async fn start_connect(
headers: HeaderMap,
body: Option<Json<StartConnectReq>>,
) -> 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();
// Resolve the target merchant profile (defaulting to the default).
@@ -115,7 +125,15 @@ pub async fn start_connect(
rand::thread_rng().fill_bytes(&mut 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(
&state.db,
&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)?;
@@ -226,10 +244,17 @@ pub async fn callback_get(
Ok(()) => success_page(
"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
// (e.g. a scoped key targeting a mainnet store -> 400) surfaces as a
// non-2xx an agent can detect, not a misleading 200. Matches the POST
// callback, which propagates the status via `?`.
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
// kicked off the connect flow. NULL falls back to the default
// 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
.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)
.await?
.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()
))?;
// 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.
let mut raw_secret = [0u8; 32];
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;
}
let network_str = resolved_network.map(|n| n.as_str());
tracing::info!(
provider_id = %provider_id,
merchant_profile_id = %profile.id,
store = %store.id,
store_name = %store.name,
webhook_id = %created_webhook.id,
scoped = auth_state.scoped_initiator,
network = network_str.unwrap_or("master/any"),
"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(())
}
+38 -2
View File
@@ -400,7 +400,8 @@ const SPEC_JSON: &str = r##"{
"type": "object",
"properties": {
"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"]
} } }
@@ -418,9 +419,44 @@ const SPEC_JSON: &str = r##"{
"/v1/admin/tier": {
"get": {
"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" } }
}
},
"/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)" } }
}
}
}
}"##;
+84
View File
@@ -366,3 +366,87 @@ pub async fn list_payment_methods(
.cloned()
.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))
}
+42 -9
View File
@@ -79,23 +79,47 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
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`
/// (multi-provider model, migration 0022) names which merchant profile
/// the resulting provider row should attach to when the callback fires
/// — 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(
pool: &SqlitePool,
token: &str,
merchant_profile_id: Option<&str>,
scoped_initiator: bool,
actor_hash: Option<&str>,
) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO btcpay_authorize_state (state_token, merchant_profile_id, created_at) \
VALUES (?, ?, ?)",
"INSERT INTO btcpay_authorize_state \
(state_token, merchant_profile_id, created_at, scoped_initiator, initiator_actor_hash) \
VALUES (?, ?, ?, ?, ?)",
)
.bind(token)
.bind(merchant_profile_id)
.bind(&now)
.bind(scoped_initiator as i64)
.bind(actor_hash)
.execute(pool)
.await
.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.
/// Consumes (deletes) the token on success so a replay fails, and
/// returns the `merchant_profile_id` recorded at start-connect time so
/// the callback knows which profile to attach the new provider to.
/// Consumes (deletes) the token on success so a replay fails, and returns the
/// recorded `AuthorizeState` (profile + initiator) so the callback knows which
/// profile to attach to and whether to apply the scoped network gate.
pub async fn consume_authorize_state(
pool: &SqlitePool,
token: &str,
) -> Result<Option<String>> {
) -> Result<AuthorizeState> {
use sqlx::Row;
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
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 >= ?",
)
.bind(token)
@@ -130,11 +155,19 @@ pub async fn consume_authorize_state(
let Some(row) = row else {
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 = ?")
.bind(token)
.execute(pool)
.await?;
Ok(merchant_profile_id)
Ok(state)
}
+1
View File
@@ -8,4 +8,5 @@
pub mod client;
pub mod config;
pub mod network;
pub mod webhook;
+160
View File
@@ -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());
}
}
+36 -13
View File
@@ -59,23 +59,46 @@ pub enum AppError {
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 {
fn into_response(self) -> Response {
let (status, code) = match &self {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
AppError::LicenseInvalid(_) => (StatusCode::OK, "invalid"),
AppError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream_error"),
AppError::BtcpayNotConfigured => (StatusCode::SERVICE_UNAVAILABLE, "btcpay_not_configured"),
AppError::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"),
AppError::ServiceUnavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable"),
AppError::PaymentRequired { .. } => (StatusCode::PAYMENT_REQUIRED, "tier_cap"),
let status = self.status_code();
let code = match &self {
AppError::NotFound(_) => "not_found",
AppError::BadRequest(_) => "bad_request",
AppError::Unauthorized => "unauthorized",
AppError::Forbidden => "forbidden",
AppError::Conflict(_) => "conflict",
AppError::LicenseInvalid(_) => "invalid",
AppError::Upstream(_) => "upstream_error",
AppError::BtcpayNotConfigured => "btcpay_not_configured",
AppError::TooManyRequests(_) => "rate_limited",
AppError::ServiceUnavailable(_) => "service_unavailable",
AppError::PaymentRequired { .. } => "tier_cap",
AppError::Database(_) | AppError::Internal(_) => {
tracing::error!(error = %self, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
"internal_error"
}
};
+193 -1
View File
@@ -86,6 +86,17 @@ async fn make_pool() -> (SqlitePool, NamedTempFile) {
/// - `self_tier = Tier::Unlicensed` inherits Creator-tier caps (5
/// products, 5 codes, etc.). Plenty for the small fixtures here.
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 keypair = crypto::keys::load_or_generate(&pool)
.await
@@ -103,7 +114,7 @@ async fn make_test_state() -> (AppState, NamedTempFile) {
btcpay_webhook_secret: None,
public_base_url: "http://keysat.test".to_string(),
operator_name: Some("Test Operator".into()),
sandbox_mode: false,
sandbox_mode,
};
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);
}
/// 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`
/// entitlement) with 402. Switching the daemon's self-tier to a
/// 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"
);
}
+17 -7
View File
@@ -43,14 +43,24 @@ Individual stages (`boot-fixture.sh`, `provision.sh`, `serve-docs.sh`,
marketing copy), tear down, and re-run on a fresh fixture.
3. Repeat until `completed-clean`.
## Stage 2 (gated, not built yet)
## Stage 2 (buyer pays on regtest) — built, `completed-clean`
The buyer-pays-on-regtest path needs Keysat to ship `payment_providers:write` +
the sandbox-mode daemon flag + the network gate (slices 35, in progress). It
adds a Dockerized BTCPay regtest stack and grants the agent
`merchant-onboard` + `payment_providers:write` so it can connect BTCPay
(regtest) and drive a test buyer payment end to end. Connecting a *mainnet*
wallet stays operator-only by design — that boundary is a feature, not a gap.
Lives in `stage2/`. Boots a **sandbox** daemon (`KEYSAT_SANDBOX_MODE=1`) wired to
a Dockerized BTCPay **regtest** stack and grants the agent `merchant-onboard` +
`payment_providers:write` so it connects BTCPay (regtest) and drives a test buyer
payment end to end. Connecting a *mainnet* wallet stays operator-only by design —
that boundary is a feature, not a gap.
```sh
(cd stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d) # one-time
./stage2/run-stage2.sh # boots sandbox daemon + regtest wiring + scoped key
# feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
```
- `stage2/btcpay-regtest/` — the BTCPay regtest compose + de-risk probe (`FINDINGS.md`).
- `stage2/validate-gate.sh` — end-to-end gate check (deny mainnet/undetermined, allow regtest).
- `stage2/buyer-pay.sh` — the test buyer's wallet (pay invoice on regtest + mine).
- `stage2/STAGE2-RESULT.md` — convergence + the publishable walkthrough.
## Requirements
@@ -0,0 +1,73 @@
# Stage 2 result — agent connects BTCPay (regtest) + buyer pays (payments)
**Verdict: `completed-clean` on run 3 (0 findings).** A fresh adopter, using only the
published docs and a **scoped** key (`merchant-onboard` + `payment_providers:write`, no
master key), can connect a regtest BTCPay over the API with **no browser step**, stand up
a paid product, produce a buyer checkout, and have a **real (regtest) on-chain payment
settle into a signed license** that validates offline.
This is the buyer-pays half of the onboarding harness (Stage 1 = no-payments SDK
integration). It is gated on the **agent-payment-connect** daemon feature (slices 3-4):
the scoped BTCPay connect is allowed only on a **sandbox** daemon for a **non-mainnet**
network. See `plans/agent-payment-connect-scope.md` and `stage2/FINDINGS.md`.
## Method
`stage2/run-stage2.sh` boots a disposable Keysat daemon in **sandbox mode**
(`KEYSAT_SANDBOX_MODE=1`) wired to the regtest BTCPay stack (`stage2/btcpay-regtest/`),
mints a scoped key carrying `payment_providers:write`, serves `keysat-docs/` as the
corpus, and materializes a sandbox app. The daemon binds `0.0.0.0` and registers its
settle webhook via `host.docker.internal` so the BTCPay container can reach it. The
global `onboarding-tester` agent then drives the journey **docs-only**. The test buyer's
wallet is `stage2/buyer-pay.sh` (pays the invoice on regtest + mines a confirmation).
## Convergence
| Run | Verdict | Findings |
|-----|---------|----------|
| 1 | blocked-at-step-1 (docs) | 2 blockers (agent.html#not-exposed said provider-connect is master-only; the connect/status/callback endpoints absent from OpenAPI) + 2 stumbles (headless callback pattern undocumented; `payment_providers:write` scope undocumented) + 1 nit. |
| 2 | **completed-clean** | 1 doc nit (install.html BTCPay permission list wrong) + 1 harness-script bug (`buyer-pay.sh` missing `-rpcwallet`). |
| 3 | **completed-clean (0)** | none. Walkthrough harvested below. |
The capability worked end to end from run 1 (the agent connected BTCPay headlessly and got
a license); the blockers were purely that the docs *said it was impossible* and didn't
document the path.
## Doc fixes shipped this loop
**`keysat-docs/` (deploys independently):**
- `agent.html`: corrected the `#auth` master-only statement; added an **A-la-carte extra
scopes** subsection (`payment_providers:write`); narrowed `#not-exposed` to the accurate
gate (scoped connect allowed only sandbox + non-mainnet; disconnect + production/mainnet
stay master-only); added the **Connect BTCPay programmatically (sandbox)** workflow
(`#connect-btcpay`) with the 3-step API flow.
- `install.html`: corrected the BTCPay permission list to the five the daemon actually
requests; added an "automating setup?" pointer to the agent path.
**`licensing-service/src/api/openapi.rs` (served spec; ships next daemon release):**
- Added `/v1/admin/btcpay/connect`, `/v1/btcpay/authorize/callback`,
`/v1/admin/btcpay/status`, `/v1/admin/btcpay/disconnect`; added the `scopes` field to
scoped-key creation; noted the read-only `sandbox` flag on `/v1/admin/tier`.
## Reproduce
```sh
(cd stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d) # one-time
./stage2/run-stage2.sh # boots sandbox daemon + regtest wiring + scoped key
# feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
./teardown.sh runs/<id> # stops daemon + docs server
```
## Publishable walkthrough (harvested, run 3)
All it took, on a sandbox Keysat with a scoped `payment_providers:write` key and a regtest
BTCPay store key (no master key, no browser):
1. **Connect BTCPay**`POST /v1/admin/btcpay/connect` -> `state`; then
`GET /v1/btcpay/authorize/callback?state=<state>&apiKey=<btcpay_store_key>`; confirm with
`GET /v1/admin/btcpay/status`.
2. **Define a paid product**`POST /v1/admin/products` + `POST /v1/admin/policies`.
3. **Create a checkout**`POST /v1/purchase` -> `checkout_url` + `amount_sats`.
4. **Buyer pays** (regtest on-chain), daemon settles via webhook, `GET /v1/purchase/<id>`
returns `status: settled` + a signed `license_key`.
5. **Validate**`POST /v1/validate` -> `ok: true` with the tier's entitlements.
@@ -0,0 +1,2 @@
probe-out/
.live-env
@@ -0,0 +1,66 @@
# De-risk result — BTCPay regtest network detection (agent-payment-connect slice 3)
**Verdict: the spec's primary network-detection assumption (§6.1) is VALIDATED against
a live regtest BTCPay 2.x. No blocker; slice 3 needs no extra OAuth permission.**
Rig: `docker-compose.yml` in this dir — bitcoind(regtest) + NBXplorer + postgres +
btcpayserver `2.0.6`. Validated 2026-06-16. Probe: `probe.sh`; raw payloads in
`probe-out/`. Bring up `docker compose -p keysat-btcpay up -d`; tear down
`docker compose -p keysat-btcpay down -v`.
## What the gate will actually see
1. **Payment-method id is `BTC-CHAIN`** on BTCPay 2.x. Posting to the legacy `.../BTC/...`
path is normalized to `BTC-CHAIN`. **Do not hardcode** — BTCPay 1.x used `BTC`. Slice 3
should read `paymentMethodId` from the list and pick the on-chain BTC method
(id ∈ {`BTC-CHAIN`,`BTC`}, not Lightning).
2. **Primary signal — receive address HRP (spec §6.1 primary), CONFIRMED:**
`GET /api/v1/stores/{id}/payment-methods/BTC-CHAIN/wallet/address`
`{"address":"bcrt1qwsh9ua5qeutshvrhz474uduwqlw8gfukfpc8vt","keyPath":"0/0","paymentLink":...}`
`bcrt1…` HRP ⇒ **regtest** ⇒ non-mainnet ⇒ scoped connect allowed (on a sandbox daemon).
Classification table (validated regtest arm; others by HRP spec):
`bc1`/base58 `1`,`3` → mainnet (deny scoped) · `tb1` → testnet/signet · `bcrt1` → regtest ·
base58 `m`,`n`,`2` → test/regtest.
3. **Secondary signal — derivation, CONFIRMED but field name differs from the spec.**
The spec says `derivationScheme`; on BTCPay 2.x Greenfield it is
**`config.accountDerivation`** (and `config.signingKey`, `config.accountKeySettings[].accountKey`),
value `tpubDC…` for regtest/testnet (mainnet → `xpub/ypub/zpub`). The BIP-84 account path
is `84'/1'/0'` — coin-type `1'` is itself a testnet/regtest marker. **Requires
`?includeConfig=true`** — see permission note below.
## Permission — the daemon already has enough
- The daemon's BTCPay OAuth (`REQUESTED_PERMISSIONS`, `btcpay_authorize.rs:45`) already
requests **`btcpay.store.canmodifystoresettings`** (for webhook registration).
- Empirically, with a token holding only `canmodifystoresettings`:
`wallet/address`**HTTP 200**, and `payment-methods?includeConfig=true` → config **visible**.
- `wallet/address` specifically needs `canmodifystoresettings` (`canviewstoresettings`
**403**). The `config`/derivation path needs only `canviewstoresettings`.
- ⇒ **Slice 3 can use EITHER signal with the key it already obtains at connect. No new
OAuth scope.** Recommend the **address-HRP path** (spec's primary; one call; unambiguous).
## Fail-closed cases (all confirmed → treat as mainnet → master-only)
- No on-chain wallet configured → `GET payment-methods` returns `[]` (no BTC-CHAIN method).
- `wallet/address` on a store with no wallet → **HTTP 503** `"BTC-CHAIN services are not
currently available"`. (Same 503 also appears transiently while BTCPay is not yet
`synchronized:true` — at operator connect time it will be synced, but treat any non-2xx /
missing address / unrecognized HRP as "cannot determine" ⇒ deny scoped, require master.)
## Implication for the daemon client (slice 3)
The existing `btcpay/client.rs::list_payment_methods` calls `GET .../payment-methods`
**without** `includeConfig`, so today it sees `config:null` (confirmed). To detect network,
add a small client fn that GETs `.../payment-methods/{pmid}/wallet/address` and classifies
the HRP (preferred), or pass `?includeConfig=true` and read `config.accountDerivation`.
Resolve target network **before persisting** the provider (spec §7).
## Rig gotcha (for whoever rebuilds this)
NBXplorer defaults to cookie auth; with separate datadir volumes BTCPay can't read the
cookie → `401` → BTCPay never reaches `synchronized:true` → on-chain `BTC-CHAIN` service
stays unavailable (`503`). Fix used here: `NBXPLORER_NOAUTH=1` (fine for a throwaway
regtest box). A production-faithful harness would instead share NBXplorer's datadir volume
into BTCPay so the cookie is shared.
@@ -0,0 +1,87 @@
# Throwaway BTCPay Server regtest stack — de-risk rig for agent-payment-connect
# network detection (spec §6.1). NOT a production deployment, NOT yet wired into
# the Stage 2 harness. Bring up: docker compose -p keysat-btcpay up -d
# Tear down (incl. volumes): docker compose -p keysat-btcpay down -v
#
# Ports published to the host:
# BTCPay UI/Greenfield API → http://127.0.0.1:49392
# bitcoind regtest RPC → 127.0.0.1:43782 (user/pass keysat/keysat)
services:
bitcoind:
image: btcpayserver/bitcoin:28.1
environment:
BITCOIN_NETWORK: regtest
BITCOIN_EXTRA_ARGS: |
rpcuser=keysat
rpcpassword=keysat
rpcbind=0.0.0.0:43782
rpcallowip=0.0.0.0/0
port=39388
whitelist=0.0.0.0/0
zmqpubrawblock=tcp://0.0.0.0:28332
zmqpubrawtx=tcp://0.0.0.0:28333
fallbackfee=0.0002
txindex=1
expose:
- "43782"
- "39388"
- "28332"
- "28333"
ports:
- "127.0.0.1:43782:43782"
volumes:
- bitcoin_datadir:/data
postgres:
image: postgres:13.13
environment:
POSTGRES_HOST_AUTH_METHOD: trust
volumes:
- postgres_datadir:/var/lib/postgresql/data
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.22
restart: unless-stopped
environment:
NBXPLORER_NETWORK: regtest
NBXPLORER_NOAUTH: "1"
NBXPLORER_BIND: 0.0.0.0:32838
NBXPLORER_TRIMEVENTS: "10000"
NBXPLORER_SIGNALFILESDIR: /datadir
NBXPLORER_CHAINS: "btc"
NBXPLORER_BTCRPCURL: http://bitcoind:43782/
NBXPLORER_BTCRPCUSER: keysat
NBXPLORER_BTCRPCPASSWORD: keysat
NBXPLORER_BTCNODEENDPOINT: bitcoind:39388
NBXPLORER_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=nbxplorer;MaxPoolSize=20;Database=nbxplorer
depends_on:
- bitcoind
- postgres
volumes:
- nbxplorer_datadir:/datadir
btcpayserver:
image: btcpayserver/btcpayserver:2.0.6
restart: unless-stopped
environment:
BTCPAY_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;MaxPoolSize=20;Database=btcpayserver
BTCPAY_NETWORK: regtest
BTCPAY_BIND: 0.0.0.0:49392
BTCPAY_ROOTPATH: /
BTCPAY_PROTOCOL: http
BTCPAY_CHAINS: "btc"
BTCPAY_BTCEXPLORERURL: http://nbxplorer:32838/
BTCPAY_DEBUGLOG: btcpay.log
ports:
- "127.0.0.1:49392:49392"
depends_on:
- nbxplorer
- postgres
volumes:
- btcpay_datadir:/datadir
volumes:
bitcoin_datadir:
postgres_datadir:
nbxplorer_datadir:
btcpay_datadir:
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# De-risk probe for agent-payment-connect network detection (spec §6.1).
# Stands up a store + on-chain regtest wallet on the local BTCPay regtest stack,
# then dumps the exact Greenfield responses the slice-3 gate would consult:
# - GET /api/v1/stores/{id}/payment-methods (paymentMethodId form? derivationScheme exposed?)
# - GET /api/v1/stores/{id}/payment-methods/{pmid}/wallet/address (bcrt1… prefix?)
# Read-only against Keysat; only mutates the throwaway BTCPay instance.
set -uo pipefail
BASE="${BTCPAY_BASE:-http://127.0.0.1:49392}"
ADMIN_EMAIL="admin@keysat.local"
ADMIN_PW="keysatregtest1!"
OUT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/probe-out"
mkdir -p "$OUT_DIR"
hr(){ printf '\n\033[1;36m=== %s ===\033[0m\n' "$*"; }
jqp(){ jq . 2>/dev/null || cat; }
# --- 0. wait for BTCPay --------------------------------------------------------
hr "0. waiting for BTCPay health at $BASE"
for i in $(seq 1 120); do
if curl -fsS "$BASE/api/v1/health" >/dev/null 2>&1; then break; fi
sleep 2
[[ $i == 120 ]] && { echo "BTCPay never became healthy"; exit 1; }
done
curl -fsS "$BASE/api/v1/health" | jqp
# --- 1. create first admin (unauthenticated, only works on a fresh instance) ---
hr "1. create first admin (idempotent: 'already exists' is fine)"
curl -sS -X POST "$BASE/api/v1/users" \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PW\",\"isAdministrator\":true}" | jqp
# Basic-auth header for subsequent Greenfield calls.
AUTH=(-u "$ADMIN_EMAIL:$ADMIN_PW")
# --- 2. create a store ---------------------------------------------------------
hr "2. create store"
STORE_JSON="$(curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/stores" \
-H 'Content-Type: application/json' -d '{"name":"Keysat Regtest Co"}')"
echo "$STORE_JSON" | jqp
STORE_ID="$(echo "$STORE_JSON" | jq -r '.id')"
echo "STORE_ID=$STORE_ID"
[[ -z "$STORE_ID" || "$STORE_ID" == null ]] && { echo "no store id"; exit 1; }
# --- 3. generate an on-chain wallet; try BTC-CHAIN then BTC --------------------
gen_body='{"savePrivateKeys":false,"importKeysToRPC":false,"wordList":"English","wordCount":12,"scriptPubKeyType":"Segwit"}'
PMID=""
for cand in BTC-CHAIN BTC; do
hr "3. generate wallet on pmid=$cand"
code="$(curl -sS -o "$OUT_DIR/gen-$cand.json" -w '%{http_code}' "${AUTH[@]}" \
-X POST "$BASE/api/v1/stores/$STORE_ID/payment-methods/$cand/wallet/generate" \
-H 'Content-Type: application/json' -d "$gen_body")"
echo "HTTP $code"; cat "$OUT_DIR/gen-$cand.json" | jqp
if [[ "$code" == 2* ]]; then PMID="$cand"; break; fi
done
[[ -z "$PMID" ]] && echo "!! wallet generate failed for both pmid forms (see above)"
# --- 4. mine some regtest blocks so the wallet has a usable address ------------
hr "4. mine regtest blocks"
ADDR_FOR_MINE="$(docker exec keysat-btcpay-bitcoind-1 bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 getnewaddress 2>/dev/null || true)"
echo "miner address: ${ADDR_FOR_MINE:-<none>}"
if [[ -n "$ADDR_FOR_MINE" ]]; then
docker exec keysat-btcpay-bitcoind-1 bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 generatetoaddress 101 "$ADDR_FOR_MINE" >/dev/null 2>&1 \
&& echo "mined 101 blocks" || echo "mine failed (non-fatal for detection probe)"
fi
# --- 5. THE PAYLOADS the slice-3 gate consults --------------------------------
hr "5a. GET payment-methods (does it expose derivationScheme? what pmid?)"
curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_ID/payment-methods?includeConfig=true" \
| tee "$OUT_DIR/payment-methods.json" | jqp
hr "5b. GET wallet/address (THE network artifact — expect bcrt1…)"
ADDR_JSON="$(curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_ID/payment-methods/${PMID:-BTC-CHAIN}/wallet/address")"
echo "$ADDR_JSON" | tee "$OUT_DIR/wallet-address.json" | jqp
ADDR="$(echo "$ADDR_JSON" | jq -r '.address // empty')"
# --- 6. classify --------------------------------------------------------------
hr "6. network classification"
echo "pmid used : ${PMID:-BTC-CHAIN}"
echo "receive address: ${ADDR:-<none>}"
case "$ADDR" in
bcrt1*) echo "=> prefix bcrt1 => REGTEST ✅ (non-mainnet → scoped connect allowed)";;
tb1*) echo "=> prefix tb1 => TESTNET/SIGNET (non-mainnet)";;
bc1*) echo "=> prefix bc1 => MAINNET ❌";;
[mn2]*) echo "=> legacy base58 m/n/2 => TEST/REGTEST (non-mainnet)";;
[13]*) echo "=> legacy base58 1/3 => MAINNET ❌";;
"") echo "=> NO ADDRESS (Lightning-only / unconfigured) => FAIL-CLOSED → mainnet → master-only";;
*) echo "=> UNRECOGNIZED prefix => FAIL-CLOSED → mainnet → master-only";;
esac
hr "done — raw payloads under $OUT_DIR/"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# The "test buyer's wallet": pay a BTCPay invoice on regtest by sending to its
# on-chain address from the regtest bitcoind and mining a confirmation. Used by
# the Stage 2 harness to drive settlement (BTCPay → webhook → Keysat issues the
# license) once the merchant journey has produced a checkout invoice.
#
# Usage: buyer-pay.sh <btcpay_base_url> <store_api_key> <store_id> <invoice_id>
# Prints the funding txid on success.
set -euo pipefail
BASE="${1:?btcpay base url}"; KEY="${2:?store api key}"; STORE="${3:?store id}"; INV="${4:?invoice id}"
BTND=keysat-btcpay-bitcoind-1
cli(){ docker exec "$BTND" bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 "$@"; }
# Wallet RPCs must name the wallet explicitly: NBXplorer loads its own wallet, so
# bitcoind has >1 loaded and a bare wallet call errors "Wallet file not specified".
wcli(){ cli -rpcwallet=miner "$@"; }
# Pull the invoice's on-chain payment address + BTC amount from BTCPay.
PM="$(curl -fsS -H "Authorization: token $KEY" \
"$BASE/api/v1/stores/$STORE/invoices/$INV/payment-methods")"
ADDR="$(echo "$PM" | jq -r '[.[] | select((.paymentMethodId|ascii_upcase)=="BTC-CHAIN" or (.paymentMethodId|ascii_upcase)=="BTC")][0].destination // empty')"
AMT="$(echo "$PM" | jq -r '[.[] | select((.paymentMethodId|ascii_upcase)=="BTC-CHAIN" or (.paymentMethodId|ascii_upcase)=="BTC")][0].amount // empty')"
[[ -n "$ADDR" && -n "$AMT" ]] || { echo "no on-chain payment method on invoice $INV" >&2; echo "$PM" >&2; exit 1; }
# Ensure the miner wallet has spendable coins, then pay + confirm.
cli -named createwallet wallet_name=miner load_on_startup=true >/dev/null 2>&1 || cli loadwallet miner >/dev/null 2>&1 || true
MINE_ADDR="$(wcli getnewaddress)"
cli generatetoaddress 101 "$MINE_ADDR" >/dev/null # generatetoaddress is node-level (no wallet needed)
TXID="$(wcli sendtoaddress "$ADDR" "$AMT")"
cli generatetoaddress 1 "$MINE_ADDR" >/dev/null # 1 conf (BTCPay HighSpeed settles at 0-conf seen / 1-conf)
echo "$TXID"
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Stage 2 setup: a sandbox Keysat daemon wired to the regtest BTCPay stack, a
# scoped key that can BOTH onboard a catalog AND connect a payment provider
# (merchant-onboard + payment_providers:write), the docs corpus, and a sandbox
# app — then the agent brief for the buyer-pays journey.
#
# Networking: the daemon binds 0.0.0.0 and registers its BTCPay webhook via
# host.docker.internal so the BTCPay *container* can reach it on settle; the
# agent/harness reach the daemon on 127.0.0.1. Sandbox mode + a non-mainnet
# (regtest) store are what let the scoped key connect BTCPay at all.
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib.sh"
require curl; require jq; require openssl; require node
STAGE2_DIR="$HARNESS_DIR/stage2"
BTCPAY_URL="$(grep -h KEYSAT_LIVE_BTCPAY_URL "$STAGE2_DIR/btcpay-regtest/.live-env" 2>/dev/null | cut -d= -f2-)"
BTCPAY_URL="${BTCPAY_URL:-http://127.0.0.1:49392}"
curl -fsS "$BTCPAY_URL/api/v1/health" >/dev/null 2>&1 \
|| die "regtest BTCPay not reachable at $BTCPAY_URL — run: (cd $STAGE2_DIR/btcpay-regtest && docker compose -p keysat-btcpay up -d)"
[[ -x "$DAEMON_BIN" ]] || { log "building daemon (cargo build --release)…"; ( cd "$DAEMON_DIR" && cargo build --release >/dev/null ) || die "daemon build failed"; }
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-stage2-$$"
RUN_DIR="$RUNS_DIR/$RUN_ID"; mkdir -p "$RUN_DIR/data" "$RUN_DIR/reports"
STATE="$RUN_DIR/state.env"; : > "$STATE"
PORT="$(free_port)"; MASTER="$(openssl rand -hex 32)"
BASE_URL="http://127.0.0.1:$PORT" # agent/harness-facing
PUBLIC_URL="http://host.docker.internal:$PORT" # BTCPay-container-facing (webhooks)
state_set "$STATE" RUN_ID "$RUN_ID"; state_set "$STATE" RUN_DIR "$RUN_DIR"
state_set "$STATE" PORT "$PORT"; state_set "$STATE" BASE_URL "$BASE_URL"
state_set "$STATE" MASTER_KEY "$MASTER"; state_set "$STATE" BTCPAY_URL "$BTCPAY_URL"
log "booting sandbox daemon on 0.0.0.0:$PORT (btcpay → $BTCPAY_URL)"
KEYSAT_BIND="0.0.0.0:$PORT" \
KEYSAT_DB_PATH="$RUN_DIR/data/keysat.db" \
KEYSAT_ADMIN_API_KEY="$MASTER" \
KEYSAT_SANDBOX_MODE=1 \
BTCPAY_URL="$BTCPAY_URL" \
KEYSAT_PUBLIC_URL="$PUBLIC_URL" \
KEYSAT_OPERATOR_NAME="Stage 2 Sandbox" \
nohup "$DAEMON_BIN" >"$RUN_DIR/daemon.log" 2>&1 &
state_set "$STATE" DAEMON_PID "$!"
ln -sfn "$RUN_DIR" "$CURRENT_LINK"
wait_http "$BASE_URL/healthz" 75 || { tail -20 "$RUN_DIR/daemon.log" >&2; die "daemon failed to start"; }
# Confirm the sandbox flag is actually on (the whole gate depends on it).
[[ "$(curl -fsS -H "Authorization: Bearer $MASTER" "$BASE_URL/v1/admin/tier" | jq -r '.sandbox')" == "true" ]] \
|| die "daemon did not report sandbox mode"
log "minting scoped key: merchant-onboard + payment_providers:write"
SK="$(curl -fsS -X POST "$BASE_URL/v1/admin/api-keys" -H "Authorization: Bearer $MASTER" \
-H 'Content-Type: application/json' \
-d '{"label":"stage2-agent","role":"merchant-onboard","scopes":["payment_providers:write"]}' \
| jq -r '.token')"
[[ "$SK" == ks_* ]] || die "scoped key mint failed"
state_set "$STATE" MERCHANT_KEY "$SK"
"$HARNESS_DIR/serve-docs.sh" "$RUN_DIR" >/dev/null
"$HARNESS_DIR/make-sandbox.sh" "$RUN_DIR" >/dev/null
DOCS_URL="$(state_get "$STATE" DOCS_URL)"; SANDBOX="$(state_get "$STATE" SANDBOX)"
# Two BTCPay store contexts the test buyer/agent can use (regtest store has an
# on-chain wallet; created during de-risk). The agent connects via the scoped
# key; the BTCPay credential it needs is provided as the "operator's BTCPay".
[[ -f "$STAGE2_DIR/btcpay-regtest/.live-env" ]] \
|| die ".live-env missing — run stage2/btcpay-regtest/probe.sh first to mint the BTCPay store token (GATE_TOK_REGTEST)"
source "$STAGE2_DIR/btcpay-regtest/.live-env"
cat > "$RUN_DIR/AGENT_BRIEF.md" <<EOF
# Onboarding-tester brief — Keysat Stage 2 (agent connects BTCPay regtest + buyer pays)
You are a **fresh adopter**, following \`~/Projects/standards/guides/onboarding-tester.md\`.
Reach the goal using **only the docs corpus**. Never read Keysat source to unblock
yourself — a gap in the docs is a finding.
## Goal (checkable end-state)
Acting for a merchant on a **sandbox** Keysat instance, using a **scoped, non-master**
API key (it carries \`payment_providers:write\`), and the published docs only:
1. **Connect a BTCPay payment provider** (this box's regtest BTCPay) to Keysat over the
API — no master key, no human clicking in a browser. (You hold a BTCPay credential for
the regtest server, the way an operator delegating setup would hand one to you.)
2. Create a product with a **paid** policy/tier.
3. Produce a **buyer checkout** for that product (a purchase invoice).
4. Confirm that paying the invoice issues a license (the harness will pay it on regtest if
you cannot from the docs alone — note where the docs leave that to plumbing).
Success = a paid product whose purchase, once settled, yields a valid license — reached
from the docs alone, under a scoped key, with BTCPay connected by you.
## Docs corpus (the ONLY how-to sources)
- Keysat docs site: **$DOCS_URL** (start at \`/agent.html\`, \`/integrate.html\`).
- Daemon OpenAPI: **$BASE_URL/v1/openapi.json**.
## Credentials you were handed
- Keysat server: **$BASE_URL**
- Scoped API key (merchant-onboard + payment_providers:write): **$SK**
- Regtest BTCPay server: **${KEYSAT_LIVE_BTCPAY_URL:-$BTCPAY_URL}**, store
**${KEYSAT_LIVE_BTCPAY_STORE_REGTEST:-<regtest store id>}**, BTCPay token
**${GATE_TOK_REGTEST:-<btcpay store token>}** (your "operator's BTCPay" access).
- You were NOT given the master Keysat admin key. If a step seems to need it, that is
either an intended operator-only boundary (note it) or a doc gap (log it).
## Out of corpus (do not open)
Anything under the Keysat source tree, migrations, tests, or this harness.
## Output
Write your friction report to \`$RUN_DIR/reports/friction.md\` AND return it as your final
message, in your guide's format. Most-severe-first. On \`completed-clean\`, also emit the
publishable "all the agent had to do was X, Y, Z" walkthrough (secret-free).
EOF
ok "Stage 2 staged. Run id: $RUN_ID"
cat >&2 <<EOF
Daemon (agent) : $BASE_URL (sandbox, btcpay → $BTCPAY_URL)
Docs corpus : $DOCS_URL
Scoped key : $SK
Sandbox app : $SANDBOX
Agent brief : $RUN_DIR/AGENT_BRIEF.md
Buyer-pay helper: $STAGE2_DIR/buyer-pay.sh
Tear down : $HARNESS_DIR/teardown.sh "$RUN_DIR"
EOF
echo "$RUN_ID"
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# End-to-end validation of the agent-payment-connect gate against the LIVE
# regtest BTCPay (the spec's hard requirement). Boots a throwaway Keysat daemon
# in sandbox mode pointed at the regtest BTCPay stack, mints a scoped
# `payment_providers:write` key, and drives the full OAuth round-trip for two
# stores:
# - no-wallet store → network undetermined → FAIL CLOSED → connect DENIED (400)
# - regtest store → bcrt1 address → non-mainnet → connect ALLOWED (persisted)
#
# Requires the regtest stack up (docker compose -p keysat-btcpay up -d) and
# .live-env populated (GATE_TOK_REGTEST / GATE_TOK_NOWALLET — single-store BTCPay
# tokens). Reads the daemon release binary built by `cargo build --release`.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$HERE/btcpay-regtest/.live-env"
BIN="$HERE/../../licensing-service/target/release/keysat"
[[ -x "$BIN" ]] || { echo "FAIL: release binary missing ($BIN) — run cargo build --release"; exit 1; }
PORT=$(node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close();})')
MASTER=$(openssl rand -hex 32)
TMP=$(mktemp -d)
BASE="http://127.0.0.1:$PORT"
pass=0; fail=0
ok(){ echo "$*"; pass=$((pass+1)); }
no(){ echo "$*"; fail=$((fail+1)); }
echo "== booting sandbox daemon on $BASE (btcpay → $KEYSAT_LIVE_BTCPAY_URL) =="
KEYSAT_BIND="127.0.0.1:$PORT" \
KEYSAT_DB_PATH="$TMP/keysat.db" \
KEYSAT_ADMIN_API_KEY="$MASTER" \
KEYSAT_SANDBOX_MODE=1 \
BTCPAY_URL="$KEYSAT_LIVE_BTCPAY_URL" \
KEYSAT_PUBLIC_URL="$BASE" \
KEYSAT_OPERATOR_NAME="Stage2 Gate Validation" \
nohup "$BIN" >"$TMP/daemon.log" 2>&1 &
DAEMON_PID=$!
trap 'kill $DAEMON_PID 2>/dev/null; rm -rf "$TMP"' EXIT
for i in $(seq 1 75); do curl -fsS "$BASE/healthz" >/dev/null 2>&1 && break; sleep 0.2; [[ $i == 75 ]] && { echo "FAIL: daemon never healthy"; tail -20 "$TMP/daemon.log"; exit 1; }; done
M=(-H "Authorization: Bearer $MASTER")
echo "== 1. sandbox flag surfaced read-only in /v1/admin/tier =="
[[ "$(curl -sS "${M[@]}" "$BASE/v1/admin/tier" | jq -r '.sandbox')" == "true" ]] && ok "tier.sandbox == true" || no "sandbox flag not surfaced"
echo "== 2. mint scoped merchant-onboard + payment_providers:write key =="
SK="$(curl -sS "${M[@]}" -X POST "$BASE/v1/admin/api-keys" -H 'Content-Type: application/json' \
-d '{"label":"agent","role":"merchant-onboard","scopes":["payment_providers:write"]}' | jq -r '.token')"
[[ "$SK" == ks_* ]] && ok "scoped key minted" || { no "mint failed"; }
S=(-H "Authorization: Bearer $SK")
# drive a connect: returns HTTP status of the callback. $1=btcpay token
drive_connect(){
local tok="$1"
local st; st="$(curl -sS "${S[@]}" -X POST "$BASE/v1/admin/btcpay/connect" | jq -r '.state')"
[[ -n "$st" && "$st" != null ]] || { echo "000"; return; }
curl -sS -o /tmp/gate-cb.out -w '%{http_code}' -X POST "$BASE/v1/btcpay/authorize/callback?state=$st" \
--data-urlencode "apiKey=$tok"
}
echo "== 3. DENY: scoped connect to a no-wallet store (undetermined → fail-closed) =="
code="$(drive_connect "$GATE_TOK_NOWALLET")"
if [[ "$code" == 400 ]]; then
ok "callback rejected with HTTP 400"
grep -qi "non-mainnet" /tmp/gate-cb.out && ok "rejection cites the non-mainnet restriction" || no "rejection message unexpected: $(cat /tmp/gate-cb.out | head -c200)"
else
no "expected 400, got $code ($(cat /tmp/gate-cb.out | head -c200))"
fi
[[ "$(curl -sS "${M[@]}" "$BASE/v1/admin/btcpay/status" | jq -r '.connected')" == "false" ]] && ok "no provider persisted on deny" || no "a provider was persisted despite deny!"
# The GET callback form (what the agent docs show) must ALSO deny with a 4xx,
# not a 200 error page (regression guard for the GET-handler status fix).
gst="$(curl -sS "${S[@]}" -X POST "$BASE/v1/admin/btcpay/connect" | jq -r '.state')"
gcode="$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/v1/btcpay/authorize/callback?state=$gst&apiKey=$GATE_TOK_NOWALLET")"
[[ "$gcode" == 4* ]] && ok "GET callback form denies with HTTP $gcode (not a 200 error page)" || no "GET callback returned $gcode (expected 4xx)"
echo "== 4. ALLOW: scoped connect to the regtest store (bcrt1 → non-mainnet) =="
code="$(drive_connect "$GATE_TOK_REGTEST")"
if [[ "$code" == 200 ]]; then ok "callback succeeded with HTTP 200"; else no "expected 200, got $code ($(cat /tmp/gate-cb.out | head -c300))"; fi
ST_JSON="$(curl -sS "${M[@]}" "$BASE/v1/admin/btcpay/status")"
[[ "$(echo "$ST_JSON" | jq -r '.connected')" == "true" ]] && ok "provider persisted" || no "provider not persisted on allow"
[[ "$(echo "$ST_JSON" | jq -r '.store_id')" == "$KEYSAT_LIVE_BTCPAY_STORE_REGTEST" ]] && ok "persisted store is the regtest store" || no "wrong store persisted: $(echo "$ST_JSON" | jq -c '.store_id')"
echo "== 5. scoped connect is audited with the resolved network =="
AUD="$(curl -sS "${M[@]}" "$BASE/v1/admin/audit?action=payment_provider.connect_scoped" | jq -c '.entries[0] // empty')"
echo " audit: $AUD"
echo "$AUD" | grep -qi "regtest" && ok "audit row records network=regtest" || no "audit row missing/!regtest"
echo
echo "==== RESULT: $pass passed, $fail failed ===="
[[ $fail == 0 ]] || exit 1