v0.2.0:2 — Zaprite payment provider + recurring subscriptions schema foundation

This release adds Zaprite as an alternative to BTCPay. Operators
can now choose between two payment rails:
- BTCPay: Bitcoin-only, you run the BTCPay Server yourself
- Zaprite: Bitcoin + fiat cards (USD/EUR via Stripe/Square), brokered
  by Zaprite, settles to your connected wallets

Only one is active at a time per Keysat instance. Switching requires
Disconnect → Connect; existing license keys are unaffected. Future
v0.3 work routes per-policy choice (e.g., "free tier via Zaprite,
paid tier via BTCPay") if operators want both, but for v0.2.0:2 it's
either-or.

What's in this release:

**Migration 0011 — recurring subscriptions schema (dormant).**
Adds `subscriptions` and `subscription_invoices` tables, plus
`is_recurring`/`renewal_period_days`/`grace_period_days` (default 7)/
`trial_days` (default 0) on policies. No daemon code uses these
yet — phases 2-6 of RECURRING_SUBSCRIPTIONS_DESIGN.md land in
follow-up commits. Migration regression test covers the additive
contract against populated data.

**Migration 0012 — zaprite_config.** Singleton-row table for the
operator's Zaprite API key + base URL + recorded webhook id.
Mirrors btcpay_config from migration 0002.

**ZapriteProvider implementation.** New module at
src/payment/zaprite/ with client.rs (HTTP, Bearer auth), config.rs
(DB persistence), provider.rs (PaymentProvider trait impl). Maps
Zaprite's currency enum (BTC/USD/EUR) to/from the Money type;
maps Zaprite's order status enum (PENDING/PROCESSING/PAID/COMPLETE/
OVERPAID/UNDERPAID) to ProviderInvoiceStatus.

**Webhook security via externalUniqId round-trip.** Zaprite does
NOT publish a webhook signature scheme (verified May 2026 against
public OpenAPI + dashboard). Their docs explicitly designate
receiver-side idempotency as the security model. Keysat's defense:
attach our local invoice UUID as externalUniqId at order creation,
then trust the webhook only insofar as the order id resolves to
a local invoice in an expected state. Documented in detail in the
payment::zaprite module-level comment + the validate_webhook
docstring.

**Admin endpoints.**
- POST /v1/admin/zaprite/connect: validates the API key by pinging
  GET /v1/orders before persisting; swaps active provider atomically
- POST /v1/admin/zaprite/disconnect: clears stored creds + provider
- GET  /v1/admin/zaprite/status: read-only connection snapshot
- POST /v1/zaprite/webhook: webhook landing route (alias of the
  existing /v1/btcpay/webhook handler since validate_webhook is
  trait-level)

**StartOS Actions** under a new "Zaprite" group: Connect Zaprite,
Check Zaprite connection, Disconnect Zaprite. Operator pastes the
API key into a masked input; daemon validates + saves.

**Tests.** Two new in tests/api.rs (zaprite_webhook_event_parsing
covers the full event-type mapping + missing-id rejection +
malformed-JSON rejection; zaprite_provider_kind pins the
identification). Migration regression test for 0011. Test count
grows 39 → 41.

Operators on BTCPay see no change. Operators wanting Zaprite go
through the StartOS Actions tab → Connect Zaprite, paste their
API key, register a webhook in Zaprite's dashboard pointing at
their public Keysat URL + /v1/zaprite/webhook.

Recurring subscriptions are NOT yet operator-visible — schema only
in this release. Daemon-code that uses the subscriptions tables
(renewal worker, validate-hot-path subscription branch, admin UI)
lands in subsequent commits per the design doc's phased plan.
This commit is contained in:
Grant
2026-05-08 16:34:58 -05:00
parent 4251e96082
commit 9eba309a8f
12 changed files with 1130 additions and 6 deletions
@@ -0,0 +1,29 @@
-- Zaprite payment-provider config storage.
--
-- Mirror of btcpay_config from migration 0002, scoped to what
-- Zaprite actually requires:
-- - api_key: bearer token from app.zaprite.com/.../settings/api,
-- scoped per-organization. One key per Keysat instance.
-- - base_url: defaults to https://api.zaprite.com but kept
-- overridable for sandbox / future regional endpoints.
-- - webhook_id: nullable. Operators configure the webhook on
-- Zaprite's side (their dashboard); we record the id we get
-- back so we can list/delete it during a Disconnect.
-- - No webhook_secret column — Zaprite's webhook delivery model
-- doesn't expose HMAC signatures. Authentication is the
-- externalUniqId round-trip pattern instead (see
-- ZAPRITE_INTEGRATION_SPEC.md "Open questions resolved" §2).
--
-- Singleton row (id = 1) like btcpay_config — Keysat connects to
-- exactly one Zaprite organization per instance.
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS zaprite_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
api_key TEXT NOT NULL,
base_url TEXT NOT NULL DEFAULT 'https://api.zaprite.com',
webhook_id TEXT,
connected_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
+23
View File
@@ -73,6 +73,7 @@ pub mod community;
pub mod db_info; pub mod db_info;
pub mod rates_admin; pub mod rates_admin;
pub mod recover; pub mod recover;
pub mod zaprite_authorize;
pub mod webhook; pub mod webhook;
pub mod webhook_deliveries; pub mod webhook_deliveries;
pub mod webhook_endpoints; pub mod webhook_endpoints;
@@ -228,6 +229,28 @@ pub fn router(state: AppState) -> Router {
"/v1/admin/btcpay/payment-methods", "/v1/admin/btcpay/payment-methods",
get(btcpay_authorize::payment_methods), get(btcpay_authorize::payment_methods),
) )
// Zaprite — alternative payment provider with native fiat-card
// support. The connect flow is much simpler than BTCPay's because
// Zaprite doesn't have an OAuth-style consent endpoint; the
// operator pastes an API key from their Zaprite dashboard.
.route(
"/v1/admin/zaprite/connect",
post(zaprite_authorize::connect),
)
.route(
"/v1/admin/zaprite/disconnect",
post(zaprite_authorize::disconnect),
)
.route(
"/v1/admin/zaprite/status",
get(zaprite_authorize::status),
)
// Zaprite webhook landing — operator points Zaprite's
// webhook setting at this URL. Same handler as
// /v1/btcpay/webhook because the underlying validate_webhook
// is on the trait surface and the active provider self-
// identifies its event shape.
.route("/v1/zaprite/webhook", post(webhook::handle))
.route("/v1/admin/products", post(admin::create_product)) .route("/v1/admin/products", post(admin::create_product))
.route( .route(
"/v1/admin/products/:id", "/v1/admin/products/:id",
@@ -0,0 +1,192 @@
//! Zaprite connect / disconnect / status admin endpoints.
//!
//! Zaprite doesn't expose an OAuth-style consent flow the way
//! BTCPay does — there's no `/authorize` redirect chain. Operators
//! just create an API key in their Zaprite dashboard and paste it
//! in. So this module is much smaller than `btcpay_authorize.rs`:
//! a single connect endpoint validates + stores the key, a
//! disconnect endpoint wipes it, a status endpoint reports state.
//!
//! The active provider on `AppState` is swapped atomically as part
//! of connect/disconnect so request handlers immediately see the
//! new state without a daemon restart.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use crate::payment::zaprite::{
config as zaprite_config, ZapriteClient, ZapriteProvider,
};
use axum::{extract::State, http::HeaderMap, Json};
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
const DEFAULT_BASE_URL: &str = "https://api.zaprite.com";
#[derive(Debug, Deserialize)]
pub struct ConnectReq {
pub api_key: String,
/// Optional override — defaults to https://api.zaprite.com.
/// Useful for sandbox orgs that point at a different host or
/// for future regional endpoints.
#[serde(default)]
pub base_url: Option<String>,
}
/// `POST /v1/admin/zaprite/connect` — validate + store an API
/// key, then swap the active payment provider to Zaprite. The
/// operator pastes the key from
/// `app.zaprite.com/.../settings/api`.
///
/// Validates the key by calling `GET /v1/orders?limit=1` against
/// Zaprite — auth-guarded, so a 200 confirms the key works for
/// the right org. A 401 / 403 / network error short-circuits
/// before we persist anything.
pub async fn connect(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<ConnectReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let api_key = req.api_key.trim().to_string();
if api_key.is_empty() {
return Err(AppError::BadRequest("api_key is required".into()));
}
let base_url = req
.base_url
.as_deref()
.map(|s| s.trim().trim_end_matches('/'))
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_BASE_URL)
.to_string();
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
return Err(AppError::BadRequest(
"base_url must start with http:// or https://".into(),
));
}
// Smoke-test the key before saving anything. Zaprite will
// 401 a bad key — surface that as a clean operator-facing
// error rather than letting it crash later in the purchase
// flow.
let client = ZapriteClient::new(&base_url, &api_key);
client.ping().await.map_err(|e| {
AppError::Upstream(format!(
"Zaprite key validation failed (key may be invalid or revoked): {e:#}"
))
})?;
// Persist + swap.
zaprite_config::save(
&state.db,
&zaprite_config::ZapriteConfig {
api_key: api_key.clone(),
base_url: base_url.clone(),
webhook_id: None, // operator configures the webhook in Zaprite's dashboard
},
)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("save zaprite_config: {e:#}")))?;
let provider = ZapriteProvider::new(client);
state
.set_payment_provider(Arc::new(provider))
.await;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"zaprite.connect",
Some("payment_provider"),
Some("zaprite"),
ip.as_deref(),
ua.as_deref(),
&json!({ "base_url": base_url }),
)
.await;
Ok(Json(json!({
"ok": true,
"provider": "zaprite",
"base_url": base_url,
})))
}
/// `POST /v1/admin/zaprite/disconnect` — wipe the stored key,
/// clear the active provider. Operator should also delete the
/// corresponding webhook on Zaprite's side, but we don't reach
/// out to Zaprite to delete it — the operator uses Zaprite's
/// dashboard for that. We can't delete it programmatically because
/// Zaprite's webhook-management endpoints aren't on the public
/// OpenAPI we have access to.
pub async fn disconnect(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// No-op if nothing's connected.
let existing = zaprite_config::load(&state.db).await.map_err(|e| {
AppError::Internal(anyhow::anyhow!("load zaprite_config: {e:#}"))
})?;
if existing.is_none() {
return Ok(Json(json!({
"ok": true,
"noop": true,
"message": "Zaprite was not connected",
})));
}
zaprite_config::clear(&state.db).await.map_err(|e| {
AppError::Internal(anyhow::anyhow!("clear zaprite_config: {e:#}"))
})?;
state.clear_payment_provider().await;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"zaprite.disconnect",
Some("payment_provider"),
Some("zaprite"),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
Ok(Json(json!({
"ok": true,
"noop": false,
"message": "Zaprite disconnected. Don't forget to delete the corresponding webhook on Zaprite's side at app.zaprite.com.",
})))
}
/// `GET /v1/admin/zaprite/status` — operator-facing connection
/// snapshot. Reports whether Zaprite is the active provider, the
/// base URL, and whether a webhook id has been recorded. Does NOT
/// return the API key (mirroring how btcpay/status redacts).
pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = zaprite_config::load(&state.db).await.map_err(|e| {
AppError::Internal(anyhow::anyhow!("load zaprite_config: {e:#}"))
})?;
let active_provider = match state.payment.read().await.as_ref() {
Some(p) => Some(p.kind().as_str().to_string()),
None => None,
};
Ok(Json(json!({
"connected": cfg.is_some(),
"active_provider": active_provider,
"base_url": cfg.as_ref().map(|c| c.base_url.clone()),
"webhook_id": cfg.as_ref().and_then(|c| c.webhook_id.clone()),
})))
}
+31 -5
View File
@@ -58,16 +58,27 @@ async fn main() -> anyhow::Result<()> {
); );
// --- payment provider (may be None until operator connects) --- // --- payment provider (may be None until operator connects) ---
let provider: Option<Arc<dyn payment::PaymentProvider>> = // Resolution order: BTCPay first (the original / default), then
load_btcpay_provider(&pool, &cfg).await.map(|p| { // Zaprite. If both are configured, BTCPay wins — operators with
// both connected get sat-priced flows through BTCPay; the
// future v0.3 multi-provider routing will let policies pick
// which provider handles which payment rail.
let provider: Option<Arc<dyn payment::PaymentProvider>> = {
if let Some(p) = load_btcpay_provider(&pool, &cfg).await {
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p); let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
arc Some(arc)
}); } else if let Some(p) = load_zaprite_provider(&pool).await {
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
Some(arc)
} else {
None
}
};
match &provider { match &provider {
Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"), Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"),
None => tracing::warn!( None => tracing::warn!(
"no payment provider yet configured — purchases will return 503 until the \ "no payment provider yet configured — purchases will return 503 until the \
operator completes the 'Connect BTCPay' flow" operator completes the 'Connect BTCPay' or 'Connect Zaprite' flow"
), ),
} }
@@ -194,3 +205,18 @@ async fn load_btcpay_provider(
} }
None None
} }
/// Load a ZapriteProvider from the DB, if the operator has previously
/// completed the Connect Zaprite flow. No env-var fallback because
/// Zaprite is brand new in this codebase — operators who want it
/// configure it via the admin UI / StartOS Action, not env vars.
async fn load_zaprite_provider(
pool: &sqlx::SqlitePool,
) -> Option<payment::zaprite::ZapriteProvider> {
if let Ok(Some(saved)) = payment::zaprite::config::load(pool).await {
let client =
payment::zaprite::ZapriteClient::new(&saved.base_url, &saved.api_key);
return Some(payment::zaprite::ZapriteProvider::new(client));
}
None
}
+1
View File
@@ -38,6 +38,7 @@ use serde::{Deserialize, Serialize};
use std::any::Any; use std::any::Any;
pub mod btcpay; pub mod btcpay;
pub mod zaprite;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -0,0 +1,181 @@
//! Thin HTTP client for Zaprite's `/v1/*` API.
//!
//! Maps directly to the OpenAPI spec at api.zaprite.com/openapi.json.
//! Returns the raw JSON shapes for now — the `ZapriteProvider` impl
//! turns them into the trait's typed enums.
use anyhow::{anyhow, Context, Result};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use serde::Serialize;
use serde_json::Value;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct ZapriteClient {
pub base_url: String,
pub api_key: String,
http: reqwest::Client,
}
/// Subset of `POST /v1/orders` request body — the fields Keysat
/// actually populates. Zaprite accepts many more (invoice line
/// items, contacts, etc.) that we don't need for the licensing
/// flow.
#[derive(Debug, Serialize)]
pub struct CreateOrderBody<'a> {
pub amount: i64,
pub currency: &'a str,
/// OUR internal invoice UUID. The webhook handler uses this
/// as the trust anchor — only orders Zaprite reports back
/// with a matching externalUniqId are honored. Zaprite does
/// NOT dedupe on this field; it's reconciliation only.
#[serde(rename = "externalUniqId")]
pub external_uniq_id: &'a str,
/// URL we send the buyer to after Zaprite finishes the
/// checkout (success or otherwise). Zaprite appends its own
/// status fragments.
#[serde(rename = "redirectUrl")]
pub redirect_url: &'a str,
/// Display label on Zaprite's checkout page + on Bitcoin
/// transaction labels.
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<&'a str>,
/// Free-form metadata Keysat round-trips for audit.
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
/// `{ email, name }` — set if the buyer provided one at
/// checkout. Zaprite uses this for receipts.
#[serde(rename = "customerData", skip_serializing_if = "Option::is_none")]
pub customer_data: Option<Value>,
/// `true` allows the buyer to save their card on Zaprite for
/// recurring charges. Set when the policy is recurring.
#[serde(rename = "allowSavePaymentProfile", skip_serializing_if = "Option::is_none")]
pub allow_save_payment_profile: Option<bool>,
}
impl ZapriteClient {
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.expect("build reqwest client");
Self {
base_url,
api_key: api_key.into(),
http,
}
}
fn auth_headers(&self) -> Result<HeaderMap> {
let mut h = HeaderMap::new();
h.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", self.api_key))
.map_err(|e| anyhow!("invalid bearer token: {e}"))?,
);
h.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(h)
}
/// `POST /v1/orders` — create an order. Returns the full order
/// JSON so the caller can pull whichever fields it needs
/// (`id`, `checkoutUrl`, `status`, etc.).
pub async fn create_order(&self, body: &CreateOrderBody<'_>) -> Result<Value> {
let url = format!("{}/v1/orders", self.base_url);
let resp = self
.http
.post(&url)
.headers(self.auth_headers()?)
.json(body)
.send()
.await
.context("Zaprite create_order request")?;
let status = resp.status();
let raw = resp.text().await.context("read create_order body")?;
if !status.is_success() {
return Err(anyhow!(
"Zaprite create_order returned HTTP {status}: {raw}"
));
}
serde_json::from_str(&raw).context("parse create_order response")
}
/// `GET /v1/orders/{id}` — fetch an order by Zaprite id OR by
/// externalUniqId (Zaprite accepts either). Used by the
/// reconcile loop to catch missed webhooks.
pub async fn get_order(&self, order_id: &str) -> Result<Value> {
let encoded = urlencoding::encode(order_id);
let url = format!("{}/v1/orders/{encoded}", self.base_url);
let resp = self
.http
.get(&url)
.headers(self.auth_headers()?)
.send()
.await
.context("Zaprite get_order request")?;
let status = resp.status();
let raw = resp.text().await.context("read get_order body")?;
if !status.is_success() {
return Err(anyhow!(
"Zaprite get_order({order_id}) returned HTTP {status}: {raw}"
));
}
serde_json::from_str(&raw).context("parse get_order response")
}
/// `POST /v1/orders/charge` — charge an order against a
/// previously-saved payment profile. Used by the recurring-
/// subscriptions renewal worker (per the
/// RECURRING_SUBSCRIPTIONS_DESIGN.md "Phase 2 — Renewal worker"
/// section). Not invoked from one-shot purchase flow.
pub async fn charge_order_with_profile(
&self,
order_id: &str,
payment_profile_id: &str,
) -> Result<Value> {
let url = format!("{}/v1/orders/charge", self.base_url);
let body = serde_json::json!({
"orderId": order_id,
"paymentProfileId": payment_profile_id,
});
let resp = self
.http
.post(&url)
.headers(self.auth_headers()?)
.json(&body)
.send()
.await
.context("Zaprite charge_order_with_profile request")?;
let status = resp.status();
let raw = resp.text().await.context("read charge body")?;
if !status.is_success() {
return Err(anyhow!(
"Zaprite charge_order_with_profile returned HTTP {status}: {raw}"
));
}
serde_json::from_str(&raw).context("parse charge response")
}
/// Smoke test for Connect-flow validation. Pings `GET /v1/orders`
/// (the list endpoint) — auth-guarded, so a 200 confirms the
/// API key works against the right org.
pub async fn ping(&self) -> Result<()> {
let url = format!("{}/v1/orders?limit=1", self.base_url);
let resp = self
.http
.get(&url)
.headers(self.auth_headers()?)
.send()
.await
.context("Zaprite ping request")?;
let status = resp.status();
if status.is_success() {
return Ok(());
}
let body = resp.text().await.unwrap_or_default();
Err(anyhow!(
"Zaprite ping returned HTTP {status}: {body}"
))
}
}
@@ -0,0 +1,64 @@
//! Persistent Zaprite connection state.
//!
//! Singleton row in `zaprite_config` (id = 1, see migration 0012).
//! Mirrors the BTCPay-config pattern: written on first connect,
//! read at startup to construct a `ZapriteProvider`.
//!
//! No webhook_secret column — Zaprite's webhook delivery is not
//! signed by the provider. See `payment::zaprite` module-level
//! comment for the security model.
use anyhow::{Context, Result};
use chrono::Utc;
use sqlx::{Row, SqlitePool};
#[derive(Debug, Clone)]
pub struct ZapriteConfig {
pub api_key: String,
pub base_url: String,
pub webhook_id: Option<String>,
}
pub async fn load(pool: &SqlitePool) -> Result<Option<ZapriteConfig>> {
let row = sqlx::query(
"SELECT api_key, base_url, webhook_id FROM zaprite_config WHERE id = 1",
)
.fetch_optional(pool)
.await
.context("loading zaprite_config")?;
Ok(row.map(|r| ZapriteConfig {
api_key: r.get("api_key"),
base_url: r.get("base_url"),
webhook_id: r.get("webhook_id"),
}))
}
pub async fn save(pool: &SqlitePool, cfg: &ZapriteConfig) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO zaprite_config(id, api_key, base_url, webhook_id, connected_at, updated_at) \
VALUES(1, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
api_key = excluded.api_key, \
base_url = excluded.base_url, \
webhook_id = excluded.webhook_id, \
updated_at = excluded.updated_at",
)
.bind(&cfg.api_key)
.bind(&cfg.base_url)
.bind(&cfg.webhook_id)
.bind(&now)
.bind(&now)
.execute(pool)
.await
.context("saving zaprite_config")?;
Ok(())
}
pub async fn clear(pool: &SqlitePool) -> Result<()> {
sqlx::query("DELETE FROM zaprite_config WHERE id = 1")
.execute(pool)
.await
.context("clearing zaprite_config")?;
Ok(())
}
@@ -0,0 +1,48 @@
//! Zaprite payment-provider implementation.
//!
//! Zaprite is an alternative to BTCPay that brokers Bitcoin
//! settlement (Lightning + on-chain via the operator's connected
//! wallet) AND fiat card payments (via Stripe / Square / etc.). It
//! lets operators accept USD/EUR card payments without running
//! their own merchant infrastructure — a meaningful market
//! expansion for sellers whose customers don't all hold BTC.
//!
//! The Keysat-side surface is identical to BTCPay because both
//! providers implement the abstract `PaymentProvider` trait. The
//! call sites in `purchase.rs`, `webhook.rs`, `reconcile.rs`, and
//! `tipping.rs` don't know or care which provider is active.
//!
//! ## Auth model
//!
//! Bearer token. Operators create an API key at
//! `app.zaprite.com/org/<org_id>/settings/api`, paste it into the
//! Keysat admin UI's "Connect Zaprite" action, and the daemon
//! stores it in `zaprite_config` (DB-backed singleton row, encrypted
//! at the StartOS volume layer).
//!
//! ## Webhook security
//!
//! Zaprite does NOT publish a webhook signature scheme — neither
//! HMAC nor JWT. Their docs explicitly call out receiver-side
//! idempotency as the security model: "process the same business
//! event more than once."
//!
//! Our defense is the **externalUniqId round-trip**. When we
//! create an order via `POST /v1/orders` we attach our local
//! invoice UUID as `externalUniqId`. When a webhook arrives, the
//! validate_webhook impl extracts the order id from the payload,
//! looks up the local invoice by Zaprite's id (which we recorded
//! at create time), and only acts if the row exists and is in an
//! expected state. An attacker spoofing a webhook would need to
//! know a UUID we never put on the wire to reach a real local
//! invoice.
//!
//! See `ZAPRITE_INTEGRATION_SPEC.md` at the repo root for the
//! full design + the API discovery notes.
pub mod client;
pub mod config;
pub mod provider;
pub use client::ZapriteClient;
pub use provider::ZapriteProvider;
@@ -0,0 +1,245 @@
//! `PaymentProvider` trait impl for Zaprite.
//!
//! Translates the Keysat-side trait surface (typed enums, sat
//! denominations, abstract `ProviderWebhookEvent`) to/from
//! Zaprite's REST API (BTC currency code, JSON status enums,
//! externalUniqId-based webhook authentication).
use crate::payment::{
CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, PaymentReceipt,
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
};
use anyhow::{anyhow, Context, Result};
use axum::http::HeaderMap;
use serde_json::Value;
use std::any::Any;
use super::client::{CreateOrderBody, ZapriteClient};
#[derive(Debug, Clone)]
pub struct ZapriteProvider {
client: ZapriteClient,
}
impl ZapriteProvider {
pub fn new(client: ZapriteClient) -> Self {
Self { client }
}
pub fn client(&self) -> &ZapriteClient {
&self.client
}
}
#[async_trait::async_trait]
impl PaymentProvider for ZapriteProvider {
fn kind(&self) -> ProviderKind {
ProviderKind::Zaprite
}
async fn create_invoice(
&self,
params: CreateInvoiceParams<'_>,
) -> Result<CreatedInvoiceHandle> {
// Zaprite's currency enum spells Bitcoin as "BTC" with
// amounts in the smallest indivisible unit (sats). Our
// trait passes Money in either SAT or fiat; map both:
// SAT → Zaprite "BTC", amount unchanged
// USD → Zaprite "USD", amount in cents (already)
// EUR → "EUR", same
// Anything else → bail; we only ship the three currencies
// the rest of Keysat understands today.
let (currency_code, amount) = match params.amount.currency.as_str() {
"SAT" => ("BTC", params.amount.amount),
"USD" => ("USD", params.amount.amount),
"EUR" => ("EUR", params.amount.amount),
other => {
return Err(anyhow!(
"ZapriteProvider.create_invoice: unsupported currency '{other}'; \
only SAT, USD, EUR mapped today"
))
}
};
// Build the Zaprite order. externalUniqId carries OUR
// invoice UUID; this is what the webhook handler uses as
// the trust anchor (see `validate_webhook` below).
let label = format!("Keysat order {}", params.external_order_id);
let body = CreateOrderBody {
amount,
currency: currency_code,
external_uniq_id: params.external_order_id,
redirect_url: params.redirect_url,
label: Some(&label),
metadata: Some(params.metadata),
customer_data: params.buyer_email.map(|email| {
serde_json::json!({ "email": email })
}),
// For one-shot purchases, don't prompt the buyer to
// save their card. The recurring-subscriptions
// renewal flow sets this to true on the FIRST
// purchase of a sub so subsequent cycles can charge
// the saved profile.
allow_save_payment_profile: None,
};
let order = self
.client
.create_order(&body)
.await
.context("ZapriteProvider.create_invoice")?;
// Pull the fields we need from the response JSON.
let provider_invoice_id = order
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("Zaprite create_order response missing 'id': {order}"))?
.to_string();
let checkout_url = order
.get("checkoutUrl")
.and_then(|v| v.as_str())
.ok_or_else(|| {
anyhow!("Zaprite create_order response missing 'checkoutUrl': {order}")
})?
.to_string();
Ok(CreatedInvoiceHandle {
provider_invoice_id,
checkout_url,
})
}
async fn get_invoice_status(
&self,
provider_invoice_id: &str,
) -> Result<ProviderInvoiceStatus> {
let order = self
.client
.get_order(provider_invoice_id)
.await
.context("ZapriteProvider.get_invoice_status")?;
// Zaprite enum: PENDING | PROCESSING | PAID | COMPLETE |
// OVERPAID | UNDERPAID. We map liberally:
// PAID, COMPLETE, OVERPAID → Settled (operator gets
// paid; buyer's overpay is
// their problem to reclaim
// via Zaprite if they want)
// UNDERPAID → Pending (buyer hasn't
// covered the full amount;
// Zaprite waits for them
// to top up before flipping
// to PAID)
// PENDING, PROCESSING → Pending
// <anything else> → Invalid (defensive)
let status_str = order
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("");
Ok(match status_str {
"PAID" | "COMPLETE" | "OVERPAID" => ProviderInvoiceStatus::Settled,
"PENDING" | "PROCESSING" | "UNDERPAID" => ProviderInvoiceStatus::Pending,
// Zaprite doesn't have explicit Expired/Refunded states
// in the enum we saw — but they may surface those via
// webhook events even when the order's "status" field
// doesn't change. Fall-through covers any future
// additions defensively.
_ => ProviderInvoiceStatus::Invalid,
})
}
/// Validate an incoming webhook delivery from Zaprite.
///
/// Zaprite does NOT expose an HMAC-style signing scheme for
/// webhooks (verified via the public OpenAPI spec + dashboard
/// inspection in May 2026 — see ZAPRITE_INTEGRATION_SPEC.md).
/// Their docs explicitly designate receiver-side idempotency
/// as the security model.
///
/// Our defense: trust the **`externalUniqId`** in the payload,
/// which we set to OUR local invoice UUID at order creation.
/// An attacker spoofing a webhook would need to know a UUID
/// we never put on the wire to reach a real local invoice.
/// The webhook handler in `api::webhook` then re-resolves the
/// row by Zaprite's `id` (also in the payload) and only acts
/// if the local row exists in an expected state.
///
/// We don't validate the headers at all here — there's no
/// signature header to validate. If Zaprite later adds HMAC
/// signing and exposes a secret, this function gets a
/// constant-time HMAC-SHA256 verification step against the
/// stored secret.
fn validate_webhook(
&self,
_headers: &HeaderMap,
body: &[u8],
) -> Result<ProviderWebhookEvent> {
let v: Value = serde_json::from_slice(body)
.context("Zaprite webhook body must be JSON")?;
// Zaprite event shape (from OpenAPI excerpt + ecosystem
// conventions): top-level `event` string + `data.id`
// (the order UUID). Examples expected:
// order.paid, order.complete, order.overpaid, order.underpaid,
// order.pending, order.expired, order.refunded
// We map liberally and let unknowns fall through to Other.
let event_type = v
.get("event")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let provider_invoice_id = v
.pointer("/data/id")
.or_else(|| v.get("orderId"))
.or_else(|| v.get("id"))
.and_then(|s| s.as_str())
.map(|s| s.to_string());
let id = provider_invoice_id.clone().ok_or_else(|| {
anyhow!("Zaprite webhook payload missing order id: {v}")
})?;
Ok(match event_type.as_str() {
"order.paid" | "order.complete" | "order.overpaid" => {
ProviderWebhookEvent::InvoiceSettled {
provider_invoice_id: id,
}
}
"order.expired" => ProviderWebhookEvent::InvoiceExpired {
provider_invoice_id: id,
},
"order.invalid" | "order.cancelled" => ProviderWebhookEvent::InvoiceInvalid {
provider_invoice_id: id,
},
"order.refunded" => ProviderWebhookEvent::InvoiceRefunded {
provider_invoice_id: id,
refunded_amount: None, // amount field shape TBD when we see a real refund event
},
other => ProviderWebhookEvent::Other {
kind: other.to_string(),
provider_invoice_id: provider_invoice_id,
},
})
}
/// Zaprite doesn't (currently) operate a Lightning node on
/// behalf of operators — they broker payments TO the operator's
/// connected wallet, but don't expose an outbound LN-pay API.
/// Tipping flows that need outbound LN payments must use a
/// BTCPay-connected operator instead.
async fn pay_lightning_invoice(&self, _bolt11: &str) -> Result<PaymentReceipt> {
anyhow::bail!(
"ZapriteProvider does not support outbound Lightning payments. \
Configure BTCPay as the active provider if you need tipping flows."
)
}
fn as_any(&self) -> &dyn Any {
self
}
}
/// Money helper for callers translating from `i64` sat amounts.
#[allow(dead_code)] // exposed for symmetry with btcpay::sats; kept for v0.3 callers
pub fn sats(amount: i64) -> Money {
Money::sats(amount)
}
+111
View File
@@ -1204,6 +1204,117 @@ async fn paid_purchase_in_usd_records_listed_currency_and_rate() {
assert_eq!(row.4, 98_000); assert_eq!(row.4, 98_000);
} }
/// Zaprite webhook authentication contract.
///
/// Zaprite doesn't sign webhooks (verified May 2026 — no HMAC,
/// no JWT, no header-based signature). The defense Keysat uses is
/// the externalUniqId round-trip: we set our local invoice UUID
/// as the order's externalUniqId at creation, and the webhook
/// handler trusts the body only insofar as we can match the
/// Zaprite order id back to a local invoice we created.
///
/// This test pins the validate_webhook impl's parsing contract:
/// - extracts the order id from `data.id` (Zaprite's payload shape)
/// - maps event types to ProviderWebhookEvent variants
/// - rejects payloads missing an order id
#[tokio::test]
async fn zaprite_webhook_event_parsing() {
use keysat::payment::{
zaprite::{ZapriteClient, ZapriteProvider},
PaymentProvider, ProviderWebhookEvent,
};
// We don't talk to Zaprite for this test — just exercise the
// pure-parsing branch of validate_webhook. Construct a client
// with bogus credentials; never used here.
let provider = ZapriteProvider::new(ZapriteClient::new(
"https://api.zaprite.test",
"test-key-not-used",
));
let headers = axum::http::HeaderMap::new();
// order.paid → InvoiceSettled
let body = br#"{"event":"order.paid","data":{"id":"zap-order-1"}}"#;
let event = provider.validate_webhook(&headers, body).expect("parse");
match event {
ProviderWebhookEvent::InvoiceSettled { provider_invoice_id } => {
assert_eq!(provider_invoice_id, "zap-order-1");
}
other => panic!("expected InvoiceSettled, got {other:?}"),
}
// order.complete + order.overpaid → also Settled (operator gets paid)
for kind in &["order.complete", "order.overpaid"] {
let body = format!(r#"{{"event":"{kind}","data":{{"id":"x"}}}}"#);
let event = provider
.validate_webhook(&headers, body.as_bytes())
.expect("parse");
assert!(
matches!(event, ProviderWebhookEvent::InvoiceSettled { .. }),
"{kind} should map to Settled"
);
}
// order.expired → InvoiceExpired
let body = br#"{"event":"order.expired","data":{"id":"zap-order-2"}}"#;
let event = provider.validate_webhook(&headers, body).expect("parse");
assert!(matches!(
event,
ProviderWebhookEvent::InvoiceExpired { .. }
));
// order.refunded → InvoiceRefunded
let body = br#"{"event":"order.refunded","data":{"id":"zap-order-3"}}"#;
let event = provider.validate_webhook(&headers, body).expect("parse");
assert!(matches!(
event,
ProviderWebhookEvent::InvoiceRefunded { .. }
));
// Unknown event type → Other (forward-compat for new event
// kinds Zaprite ships in the future)
let body = br#"{"event":"order.partially_refunded","data":{"id":"zap-order-4"}}"#;
let event = provider.validate_webhook(&headers, body).expect("parse");
match event {
ProviderWebhookEvent::Other { kind, provider_invoice_id } => {
assert_eq!(kind, "order.partially_refunded");
assert_eq!(provider_invoice_id.as_deref(), Some("zap-order-4"));
}
other => panic!("expected Other, got {other:?}"),
}
// Missing order id → reject. An attacker can't trigger any
// local state change without telling us which order to act on.
let body = br#"{"event":"order.paid","data":{}}"#;
let result = provider.validate_webhook(&headers, body);
assert!(
result.is_err(),
"payload without order id must be rejected"
);
// Malformed JSON → reject.
let body = b"not json at all";
let result = provider.validate_webhook(&headers, body);
assert!(result.is_err());
}
/// Zaprite provider self-identifies as `ProviderKind::Zaprite`.
/// Trivial but pins the kind() return for the call sites that
/// switch on provider identity (e.g., audit log strings).
#[tokio::test]
async fn zaprite_provider_kind() {
use keysat::payment::{
zaprite::{ZapriteClient, ZapriteProvider},
PaymentProvider, ProviderKind,
};
let p = ZapriteProvider::new(ZapriteClient::new(
"https://api.zaprite.test",
"test-key",
));
assert_eq!(p.kind(), ProviderKind::Zaprite);
assert_eq!(p.kind().as_str(), "zaprite");
}
/// Rate fetcher: manual pin in settings table overrides the source /// Rate fetcher: manual pin in settings table overrides the source
/// chain. Locks in the test-mode + maintenance-window contract that /// chain. Locks in the test-mode + maintenance-window contract that
/// other phases (invoice rate recording, buy-page rendering) rely on. /// other phases (invoice rate recording, buy-page rendering) rely on.
+199
View File
@@ -0,0 +1,199 @@
// Action: connect / disconnect / status for Zaprite as the active
// payment provider.
//
// Unlike BTCPay's authorize flow (OAuth-style consent redirect),
// Zaprite doesn't expose a programmatic authorize endpoint. The
// operator creates an API key in their Zaprite dashboard at
// app.zaprite.com/.../settings/api, then pastes it into the form
// here. The daemon validates the key by pinging Zaprite's API,
// then persists + swaps the active provider atomically.
//
// Webhook setup is operator-side: after connecting, the operator
// adds a webhook in Zaprite's dashboard pointing at
// <their-keysat-public-url>/v1/zaprite/webhook. There's no
// signing secret — see the daemon's payment::zaprite module
// comment for the security model (externalUniqId round-trip).
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const { InputSpec, Value } = sdk
const connectInput = InputSpec.of({
api_key: Value.text({
name: 'Zaprite API key',
description:
'Create an API key at app.zaprite.com → Settings → API. ' +
'One key per Keysat instance. The key is stored in your ' +
'StartOS volume (encrypted at rest by StartOS) and never ' +
'transmitted off your server.',
required: true,
default: null,
masked: true,
}),
base_url: Value.text({
name: 'API base URL',
description:
'Defaults to https://api.zaprite.com — only override for ' +
'sandbox organizations or future regional endpoints.',
required: false,
default: 'https://api.zaprite.com',
}),
})
export const configureZaprite = sdk.Action.withInput(
'configure-zaprite',
async () => ({
name: 'Connect Zaprite',
description:
'Connect Keysat to your Zaprite account so buyers can pay with ' +
'cards (USD/EUR) and Bitcoin via your Zaprite-connected wallets. ' +
'Use INSTEAD OF Connect BTCPay — only one payment provider can ' +
'be active at a time. Disconnect first if switching providers.',
warning:
'Switching providers does not affect already-issued license keys; ' +
'they continue to validate normally. New purchases route through ' +
'whichever provider is active at the time of checkout.',
allowedStatuses: 'only-running',
group: 'Zaprite',
visibility: 'enabled',
}),
connectInput,
// Pre-fill base_url; never pre-fill api_key (force operator to paste fresh).
async ({ effects: _effects }) => ({
api_key: '',
base_url: 'https://api.zaprite.com',
}),
async ({ effects: _effects, input }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/zaprite/connect',
{
method: 'POST',
body: JSON.stringify({
api_key: input.api_key.trim(),
base_url: (input.base_url || 'https://api.zaprite.com').trim(),
}),
},
)
if (!resp.ok) {
throw new Error(`Connect failed: HTTP ${resp.status}${await resp.text()}`)
}
const body = (await resp.json()) as {
ok: true
provider: string
base_url: string
}
return {
version: '1',
title: 'Zaprite connected',
message:
`Active payment provider is now Zaprite (${body.base_url}).\n\n` +
`Next step: register a webhook in Zaprite's dashboard pointing at:\n` +
`<your Keysat public URL>/v1/zaprite/webhook\n\n` +
`Zaprite doesn't sign webhook deliveries; Keysat authenticates ` +
`each delivery via the externalUniqId we attach at order ` +
`creation, so a webhook configured to ANY URL on your daemon ` +
`is safe even without a shared secret.`,
result: null,
}
},
)
/** Counterpart to Connect — clears stored credentials + active provider. */
export const disconnectZaprite = sdk.Action.withoutInput(
'disconnect-zaprite',
async () => ({
name: 'Disconnect Zaprite',
description:
'Disconnect Keysat from your Zaprite account. Wipes the stored ' +
'API key and clears the active provider. Existing license keys ' +
'are unaffected. Run this before re-running Connect Zaprite if ' +
'you want to rotate the key or switch organizations.',
warning:
"Don't forget to also delete the corresponding webhook in your " +
"Zaprite dashboard — Keysat can't programmatically delete it for " +
'you because the webhook-management API surface is not on the ' +
'public Zaprite OpenAPI we have access to.',
allowedStatuses: 'only-running',
group: 'Zaprite',
visibility: 'enabled',
}),
async ({ effects: _effects }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/zaprite/disconnect',
{ method: 'POST' },
)
if (!resp.ok) {
throw new Error(`Disconnect failed: HTTP ${resp.status}${await resp.text()}`)
}
const body = (await resp.json()) as
| { ok: true; noop: true; message: string }
| { ok: true; noop: false; message: string }
return {
version: '1',
title: body.noop ? 'Already disconnected' : 'Zaprite disconnected',
message: body.message,
result: null,
}
},
)
/** Quick read-only check of the current connection state. */
export const zapriteStatus = sdk.Action.withoutInput(
'zaprite-status',
async () => ({
name: 'Check Zaprite connection',
description: 'Show whether Zaprite is the active payment provider and the configured base URL.',
warning: null,
allowedStatuses: 'only-running',
group: 'Zaprite',
visibility: 'enabled',
}),
async ({ effects: _effects }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/zaprite/status',
{ method: 'GET' },
)
if (!resp.ok) {
throw new Error(`Status check failed: HTTP ${resp.status}${await resp.text()}`)
}
const body = (await resp.json()) as {
connected: boolean
active_provider: string | null
base_url: string | null
webhook_id: string | null
}
if (!body.connected) {
return {
version: '1',
title: 'Zaprite not connected',
message: 'No Zaprite credentials configured. Run "Connect Zaprite" to paste in an API key.',
result: null,
}
}
return {
version: '1',
title: body.active_provider === 'zaprite' ? 'Zaprite is active' : 'Zaprite configured (provider not active)',
message:
`Connected to ${body.base_url ?? '(unknown URL)'}.\n` +
`Active provider: ${body.active_provider ?? '(none)'}.` +
(body.active_provider === 'zaprite'
? ''
: "\n\nA different provider (likely BTCPay) is currently active. Disconnect that one first if you want Zaprite to take over."),
result: null,
}
},
)
+6 -1
View File
@@ -21,6 +21,7 @@
import { sdk } from '../sdk' import { sdk } from '../sdk'
import { activateLicense, showLicenseStatus } from './activateLicense' import { activateLicense, showLicenseStatus } from './activateLicense'
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay' import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
import { configureZaprite, disconnectZaprite, zapriteStatus } from './configureZaprite'
import { setOperatorName } from './setOperatorName' import { setOperatorName } from './setOperatorName'
import { setWebUiPassword } from './setWebUiPassword' import { setWebUiPassword } from './setWebUiPassword'
import { showCredentials } from './showCredentials' import { showCredentials } from './showCredentials'
@@ -29,10 +30,14 @@ export const actions = sdk.Actions.of()
// General // General
.addAction(setOperatorName) .addAction(setOperatorName)
.addAction(setWebUiPassword) .addAction(setWebUiPassword)
// BTCPay setup // BTCPay setup (Bitcoin-only payments via your own BTCPay Server)
.addAction(configureBtcpay) .addAction(configureBtcpay)
.addAction(btcpayStatus) .addAction(btcpayStatus)
.addAction(disconnectBtcpay) .addAction(disconnectBtcpay)
// Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker)
.addAction(configureZaprite)
.addAction(zapriteStatus)
.addAction(disconnectZaprite)
// Keysat self-license (Keysat-licenses-Keysat) // Keysat self-license (Keysat-licenses-Keysat)
.addAction(activateLicense) .addAction(activateLicense)
.addAction(showLicenseStatus) .addAction(showLicenseStatus)