WIP — BTCPay connect rewrite + webhook URL refactor + thank-you fix (part 3b)
Closes out the remaining "all callers of the deprecated active-provider
shim" surface: BTCPay connect/disconnect/status now follows the same
merchant-profile-aware shape as Zaprite did in 3a, the webhook router
gets a path-keyed shape so deliveries go to the right provider's
secret, the thank-you page reads the invoice's recorded provider id
(not "the active one"), and the legacy `activate` endpoint is removed.
migrations/0022_btcpay_state_profile.sql (new)
Adds merchant_profile_id (nullable FK) to btcpay_authorize_state so
the BTCPay OAuth state token can round-trip the operator's profile
pick between start_connect and the callback. Without this, multi-
profile operators couldn't authorize a SECOND BTCPay store onto a
non-default profile.
btcpay/config.rs
record_authorize_state takes merchant_profile_id; consume_authorize_state
now returns Option<String> so the callback knows which profile to
attach the new provider row to.
api/btcpay_authorize.rs (full rewrite)
start_connect accepts an optional merchant_profile_id (defaulting to
the default profile), refuses if that profile already has a BTCPay
provider attached (unique-index-friendly 409 message), and records
the profile id on the CSRF state token. The OAuth round-trip carries
the profile id back via the state token, not via a query param —
state-token-by-row is more robust than depending on BTCPay preserving
redirect-URL query params during the consent dance.
finish_connect (the callback's inner path):
- Pre-generates the payment_providers row id so it can be baked into
the BTCPay-side webhook callback URL.
- The webhook URL we register with BTCPay is now path-keyed:
/v1/btcpay/webhook/{provider-id}. Each profile's BTCPay store gets
isolated deliveries.
- INSERTs into payment_providers (kind='btcpay', api_key, base_url,
webhook_id, webhook_secret, store_id, attached to the chosen
profile) instead of upserting the singleton btcpay_config row.
- Populates the back-compat state.payment singleton ONLY when this
is the first provider on the default profile (so the few remaining
legacy state.payment_provider() callers still work without a
daemon restart).
disconnect accepts an optional provider_id; defaults to "the BTCPay
provider on the default profile" for back-compat with the existing
admin UI's single Disconnect button. Best-effort BTCPay-side webhook
+ API key revocation unchanged. DELETE FROM payment_providers WHERE
id = ? instead of clearing btcpay_config.
status + payment_methods report on the default-profile BTCPay row for
the legacy admin UI. Multi-profile operators will use the new
/v1/admin/merchant-profiles endpoints (part 4).
api/webhook.rs
Split into two entry points:
- handle_for_provider — the new path-keyed shape
(`/v1/{kind}/webhook/:provider_id`). Looks up the named provider
via state.payment_provider_by_id, validates the payload against
THAT specific provider's secret, then runs the inner pipeline.
- handle — back-compat for the bare /v1/{kind}/webhook path. Routes
to whichever provider is on the default profile. Kept so any
in-flight pre-:52 webhook delivery or admin misconfiguration
doesn't silently drop on the floor.
Both share an extracted handle_inner that does the actual settle /
expire / refund processing.
api/mod.rs
Route registrations:
- Adds /v1/{btcpay,zaprite}/webhook/:provider_id POST handlers.
- Removes the legacy /v1/admin/payment-provider/activate route
(the shim function is gone).
Thank-you page provider-kind lookup ports from the deprecated
read_active_provider_preference to: invoice.payment_provider_id ->
payment_providers.kind -> ProviderKind. Falls back to the default
profile's first provider if the invoice predates migration 0021.
api/payment_provider.rs
Reduced to just the back-compat status endpoint. The activate
endpoint is removed entirely — there's no "active" preference to
flip in the merchant-profile model. Status returns the same
btcpay_configured / zaprite_configured / active shape the existing
admin UI consumes, plus a new providers[] array for callers that
want the full picture.
Build: cargo check passes. Only two warnings remaining — both
expected:
- recover.rs unused-import (pre-existing, unrelated)
- SETTING_ACTIVE_PROVIDER inside the shim itself (the legacy fallback
branch in read_active_provider_preference that runs during the
pre-:52 upgrade window before migration 0020 has dropped the
settings row)
What's left for :52:
- New admin endpoints for merchant-profile + rail-preference CRUD
- Admin UI in web/index.html (biggest remaining chunk — Merchant
Profiles section + product picker + buy-page brand block +
rail picker)
- Tier-cap wire-up for unlimited_merchant_profiles
- Version bump + release notes + sandbox test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -79,14 +79,22 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a new in-flight authorize state token. The caller has already
|
||||
/// generated a cryptographically-random token.
|
||||
pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
|
||||
/// 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.
|
||||
pub async fn record_authorize_state(
|
||||
pool: &SqlitePool,
|
||||
token: &str,
|
||||
merchant_profile_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO btcpay_authorize_state (state_token, created_at) VALUES (?, ?)",
|
||||
"INSERT INTO btcpay_authorize_state (state_token, merchant_profile_id, created_at) \
|
||||
VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(token)
|
||||
.bind(merchant_profile_id)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await
|
||||
@@ -101,11 +109,17 @@ pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()
|
||||
}
|
||||
|
||||
/// Validate that `token` was issued recently and has not been consumed.
|
||||
/// Consumes (deletes) the token on success so a replay fails.
|
||||
pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
|
||||
/// 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.
|
||||
pub async fn consume_authorize_state(
|
||||
pool: &SqlitePool,
|
||||
token: &str,
|
||||
) -> Result<Option<String>> {
|
||||
use sqlx::Row;
|
||||
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
||||
let row = sqlx::query(
|
||||
"SELECT state_token FROM btcpay_authorize_state \
|
||||
"SELECT state_token, merchant_profile_id FROM btcpay_authorize_state \
|
||||
WHERE state_token = ? AND created_at >= ?",
|
||||
)
|
||||
.bind(token)
|
||||
@@ -113,13 +127,14 @@ pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<(
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if row.is_none() {
|
||||
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();
|
||||
|
||||
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
|
||||
.bind(token)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(merchant_profile_id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user