v0.1.0:24 — Keysat licensing service end-to-end

Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages,
discount codes, free-license redemption, Apply-discount UX,
self-licensing, and v0.1.0 release notes.
This commit is contained in:
Grant
2026-05-07 10:33:39 -05:00
parent 432250bffc
commit 6ac118ae70
90 changed files with 14896 additions and 524 deletions
+368
View File
@@ -0,0 +1,368 @@
//! Minimal BTCPay Greenfield API client — only the endpoints this service
//! actually calls. Add more as needs grow.
use anyhow::{Context, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Clone)]
pub struct BtcpayClient {
http: Client,
base_url: String,
api_key: String,
store_id: String,
}
/// Response subset from `POST /api/v1/stores/{storeId}/invoices`.
#[derive(Debug, Deserialize)]
pub struct CreatedInvoice {
pub id: String,
#[serde(rename = "checkoutLink")]
pub checkout_link: String,
pub status: String,
}
/// Fields we include when creating an invoice. BTCPay accepts many more; we
/// only send what we need.
#[derive(Debug, Serialize)]
struct CreateInvoiceRequest<'a> {
amount: String,
currency: &'a str,
metadata: serde_json::Value,
checkout: CheckoutOptions<'a>,
}
#[derive(Debug, Serialize)]
struct CheckoutOptions<'a> {
#[serde(rename = "redirectURL")]
redirect_url: Option<&'a str>,
#[serde(rename = "redirectAutomatically")]
redirect_automatically: bool,
}
impl BtcpayClient {
pub fn new(base_url: &str, api_key: &str, store_id: &str) -> Self {
Self {
http: Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("reqwest client"),
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
store_id: store_id.to_string(),
}
}
/// Create an invoice priced in satoshis. BTCPay accepts "BTC" currency
/// with decimal amounts; we convert sats → BTC here.
pub async fn create_invoice(
&self,
amount_sats: i64,
metadata: serde_json::Value,
redirect_url: Option<&str>,
) -> Result<CreatedInvoice> {
let url = format!(
"{}/api/v1/stores/{}/invoices",
self.base_url, self.store_id
);
let amount_btc = format!("{:.8}", amount_sats as f64 / 100_000_000.0);
let body = CreateInvoiceRequest {
amount: amount_btc,
currency: "BTC",
metadata,
checkout: CheckoutOptions {
redirect_url,
redirect_automatically: true,
},
};
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("calling BTCPay create-invoice")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay create-invoice returned {status}: {text}"
));
}
let invoice: CreatedInvoice = resp
.json()
.await
.context("parsing BTCPay create-invoice response")?;
Ok(invoice)
}
/// Pay a BOLT11 Lightning invoice from the operator's BTCPay node.
/// Used by the tip-recipient flow. Returns the BTCPay payment record so
/// the caller can extract the payment hash and surface it in the audit
/// log. Errors if the store has no internal LN node or the node refuses
/// the payment (insufficient liquidity, invoice already paid, etc.).
///
/// BTCPay endpoint:
/// POST /api/v1/stores/{storeId}/lightning/BTC/invoices/pay
/// { "BOLT11": "<bolt11>" }
///
/// The BTC path-component is the cryptoCode; on BTCPay-Server it's
/// always "BTC" for the Bitcoin Lightning network.
pub async fn pay_lightning_invoice(&self, bolt11: &str) -> Result<serde_json::Value> {
let url = format!(
"{}/api/v1/stores/{}/lightning/BTC/invoices/pay",
self.base_url, self.store_id
);
let body = json!({ "BOLT11": bolt11 });
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("calling BTCPay pay-lightning-invoice")?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay pay-lightning-invoice returned {status}: {text}"
));
}
let payment: serde_json::Value = resp
.json()
.await
.context("parsing BTCPay pay-lightning-invoice response")?;
Ok(payment)
}
/// Fetch invoice state for reconciliation on startup / admin queries.
/// Not used in the hot path; webhooks are the source of truth.
pub async fn get_invoice(&self, invoice_id: &str) -> Result<serde_json::Value> {
let url = format!(
"{}/api/v1/stores/{}/invoices/{}",
self.base_url, self.store_id, invoice_id
);
let resp = self
.http
.get(&url)
.header("Authorization", format!("token {}", self.api_key))
.send()
.await?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!(
"BTCPay get-invoice returned {}",
resp.status()
));
}
Ok(resp.json().await?)
}
#[allow(dead_code)]
pub fn store_id(&self) -> &str {
&self.store_id
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn api_key(&self) -> &str {
&self.api_key
}
// Helper to quickly construct sample metadata for invoice correlation.
pub fn invoice_metadata(product_id: &str, internal_invoice_id: &str) -> serde_json::Value {
json!({
"orderId": internal_invoice_id,
"productId": product_id,
"source": "keysat",
})
}
}
/// Standalone helpers for the authorize / bootstrap flow. These operate
/// *before* a full `BtcpayClient` exists, since we don't yet know which
/// store the API key is scoped to.
#[derive(Debug, Deserialize)]
pub struct StoreSummary {
pub id: String,
pub name: String,
}
/// List the stores the given API key has access to.
pub async fn list_stores(base_url: &str, api_key: &str) -> Result<Vec<StoreSummary>> {
let url = format!("{}/api/v1/stores", base_url.trim_end_matches('/'));
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 list-stores")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay list-stores returned {status}: {text}"
));
}
Ok(resp.json::<Vec<StoreSummary>>().await?)
}
#[derive(Debug, Deserialize)]
pub struct CreatedWebhook {
pub id: String,
pub secret: Option<String>,
}
/// Register a webhook on the given store pointing at `callback_url` and
/// subscribing to the three invoice lifecycle events we care about.
pub async fn create_webhook(
base_url: &str,
api_key: &str,
store_id: &str,
callback_url: &str,
secret: &str,
) -> Result<CreatedWebhook> {
let url = format!(
"{}/api/v1/stores/{store_id}/webhooks",
base_url.trim_end_matches('/')
);
let body = json!({
"url": callback_url,
"enabled": true,
"automaticRedelivery": true,
"secret": secret,
"authorizedEvents": {
"everything": false,
"specificEvents": [
"InvoiceSettled",
"InvoiceExpired",
"InvoiceInvalid",
],
},
});
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.post(&url)
.header("Authorization", format!("token {api_key}"))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("calling BTCPay create-webhook")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay create-webhook returned {status}: {text}"
));
}
Ok(resp.json::<CreatedWebhook>().await?)
}
/// Delete a webhook on the given store. Used by the Disconnect flow so
/// that re-authorizing later doesn't leave behind a duplicate webhook
/// pointing at this Keysat install.
pub async fn delete_webhook(
base_url: &str,
api_key: &str,
store_id: &str,
webhook_id: &str,
) -> Result<()> {
let url = format!(
"{}/api/v1/stores/{store_id}/webhooks/{webhook_id}",
base_url.trim_end_matches('/')
);
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.delete(&url)
.header("Authorization", format!("token {api_key}"))
.send()
.await
.context("calling BTCPay delete-webhook")?;
if !resp.status().is_success() && resp.status().as_u16() != 404 {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay delete-webhook returned {status}: {text}"
));
}
// 404 is treated as success — the webhook is already gone.
Ok(())
}
/// Revoke a BTCPay API key. Best-effort — failures are logged by the
/// caller but don't block the local Disconnect from completing.
pub async fn revoke_api_key(base_url: &str, api_key: &str) -> Result<()> {
let url = format!("{}/api/v1/api-keys/current", base_url.trim_end_matches('/'));
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.delete(&url)
.header("Authorization", format!("token {api_key}"))
.send()
.await
.context("calling BTCPay revoke-api-key")?;
if !resp.status().is_success() && resp.status().as_u16() != 404 {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay revoke-api-key returned {status}: {text}"
));
}
Ok(())
}
/// List the payment methods configured on a store. Used by the
/// post-connect "missing wallet" detection. Returns the raw JSON array
/// because the per-method shape varies (onchain vs LN, BTC vs altcoins).
/// Empty array → no payment methods configured.
pub async fn list_payment_methods(
base_url: &str,
api_key: &str,
store_id: &str,
) -> Result<Vec<serde_json::Value>> {
let url = format!(
"{}/api/v1/stores/{store_id}/payment-methods",
base_url.trim_end_matches('/')
);
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 list-payment-methods")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay list-payment-methods returned {status}: {text}"
));
}
let raw: serde_json::Value = resp.json().await?;
Ok(raw
.as_array()
.cloned()
.unwrap_or_default())
}
+125
View File
@@ -0,0 +1,125 @@
//! Persistent BTCPay connection state.
//!
//! Runtime credentials (API key, store, webhook secret) live in the DB so that
//! the operator can reconfigure BTCPay from the StartOS dashboard without
//! editing env vars or restarting the container.
//!
//! Written on first connect (via the authorize flow) and on explicit
//! reconnects. Read at startup to construct the `BtcpayClient`.
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use sqlx::{Row, SqlitePool};
#[derive(Debug, Clone)]
pub struct BtcpayConfig {
pub base_url: String,
pub api_key: String,
pub store_id: String,
pub webhook_id: Option<String>,
pub webhook_secret: String,
}
/// Load the current BTCPay config. Returns `None` if the operator has not
/// completed the authorize flow yet.
pub async fn load(pool: &SqlitePool) -> Result<Option<BtcpayConfig>> {
let row = sqlx::query(
"SELECT base_url, api_key, store_id, webhook_id, webhook_secret \
FROM btcpay_config WHERE id = 1",
)
.fetch_optional(pool)
.await
.context("loading btcpay_config")?;
Ok(row.map(|r| BtcpayConfig {
base_url: r.get("base_url"),
api_key: r.get("api_key"),
store_id: r.get("store_id"),
webhook_id: r.get("webhook_id"),
webhook_secret: r.get("webhook_secret"),
}))
}
/// Delete the entire BTCPay config row. Used by the Disconnect flow.
/// Subsequent calls to `load` return `None` until the operator
/// re-authorizes.
pub async fn clear(pool: &SqlitePool) -> Result<()> {
sqlx::query("DELETE FROM btcpay_config WHERE id = 1")
.execute(pool)
.await
.context("clearing btcpay_config")?;
Ok(())
}
/// Upsert the full config. Called by the authorize-callback path after the
/// service has fetched/created everything it needs from BTCPay.
pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO btcpay_config \
(id, base_url, api_key, store_id, webhook_id, webhook_secret, connected_at) \
VALUES (1, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
base_url = excluded.base_url, \
api_key = excluded.api_key, \
store_id = excluded.store_id, \
webhook_id = excluded.webhook_id, \
webhook_secret = excluded.webhook_secret, \
connected_at = excluded.connected_at",
)
.bind(&cfg.base_url)
.bind(&cfg.api_key)
.bind(&cfg.store_id)
.bind(cfg.webhook_id.as_deref())
.bind(&cfg.webhook_secret)
.bind(&now)
.execute(pool)
.await
.context("saving btcpay_config")?;
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<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO btcpay_authorize_state (state_token, created_at) VALUES (?, ?)",
)
.bind(token)
.bind(&now)
.execute(pool)
.await
.context("recording btcpay authorize state")?;
// Best-effort prune of rows older than 30 minutes.
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
let _ = sqlx::query("DELETE FROM btcpay_authorize_state WHERE created_at < ?")
.bind(&cutoff)
.execute(pool)
.await;
Ok(())
}
/// 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<()> {
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
let row = sqlx::query(
"SELECT state_token FROM btcpay_authorize_state \
WHERE state_token = ? AND created_at >= ?",
)
.bind(token)
.bind(&cutoff)
.fetch_optional(pool)
.await?;
if row.is_none() {
return Err(anyhow!("unknown or expired authorize state token"));
}
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
.bind(token)
.execute(pool)
.await?;
Ok(())
}
+11
View File
@@ -0,0 +1,11 @@
//! BTCPay Server integration.
//!
//! - [`client`] creates invoices via the BTCPay Greenfield API.
//! - [`webhook`] verifies and parses incoming webhook calls from BTCPay.
//!
//! BTCPay's Greenfield API is documented at
//! <https://docs.btcpayserver.org/API/Greenfield/v1/>.
pub mod client;
pub mod config;
pub mod webhook;
+93
View File
@@ -0,0 +1,93 @@
//! BTCPay webhook handling.
//!
//! BTCPay signs each webhook body with HMAC-SHA256 using the shared secret
//! we configured, and sends the hex digest in the `BTCPay-Sig` header as
//! `sha256=<hex>`. We verify in constant time before trusting anything.
use anyhow::{anyhow, Result};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
/// Verify the `BTCPay-Sig` header matches the raw request body.
///
/// Returns `Ok(())` on success, `Err` on any mismatch. Callers must pass the
/// raw, unmodified body — any reserialization will break the HMAC.
pub fn verify_signature(secret: &str, header_value: &str, raw_body: &[u8]) -> Result<()> {
let expected_hex = header_value
.strip_prefix("sha256=")
.ok_or_else(|| anyhow!("BTCPay-Sig header missing 'sha256=' prefix"))?;
let expected =
hex::decode(expected_hex).map_err(|_| anyhow!("BTCPay-Sig header is not hex"))?;
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC takes any key size");
mac.update(raw_body);
let computed = mac.finalize().into_bytes();
if bool::from(computed.as_slice().ct_eq(&expected)) {
Ok(())
} else {
Err(anyhow!("BTCPay webhook signature mismatch"))
}
}
/// The subset of webhook payload fields we care about. BTCPay sends many
/// event types; we key off `invoiceId` and `type` / `status`.
#[derive(Debug, serde::Deserialize)]
pub struct WebhookEvent {
#[serde(rename = "type")]
pub event_type: String,
#[serde(rename = "invoiceId")]
pub invoice_id: String,
#[serde(default)]
pub metadata: serde_json::Value,
}
impl WebhookEvent {
/// BTCPay fires event types like `InvoiceSettled`, `InvoiceExpired`,
/// `InvoiceInvalid`, `InvoiceProcessing`. We normalize to our internal
/// status vocabulary.
pub fn to_status(&self) -> Option<&'static str> {
match self.event_type.as_str() {
"InvoiceSettled" | "InvoicePaymentSettled" => Some("settled"),
"InvoiceExpired" => Some("expired"),
"InvoiceInvalid" => Some("invalid"),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verifies_correct_signature() {
let secret = "super-secret";
let body = br#"{"type":"InvoiceSettled","invoiceId":"abc"}"#;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let sig = hex::encode(mac.finalize().into_bytes());
let header = format!("sha256={sig}");
assert!(verify_signature(secret, &header, body).is_ok());
}
#[test]
fn rejects_tampered_body() {
let secret = "super-secret";
let body = br#"{"type":"InvoiceSettled","invoiceId":"abc"}"#;
let tampered = br#"{"type":"InvoiceSettled","invoiceId":"evil"}"#;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let sig = hex::encode(mac.finalize().into_bytes());
let header = format!("sha256={sig}");
assert!(verify_signature(secret, &header, tampered).is_err());
}
}