From 9eba309a8f35fa5fc5d435d5edb7dfba092a1155 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 16:34:58 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0:2=20=E2=80=94=20Zaprite=20payment=20prov?= =?UTF-8?q?ider=20+=20recurring=20subscriptions=20schema=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../migrations/0012_zaprite_config.sql | 29 +++ licensing-service/src/api/mod.rs | 23 ++ .../src/api/zaprite_authorize.rs | 192 ++++++++++++++ licensing-service/src/main.rs | 36 ++- licensing-service/src/payment/mod.rs | 1 + .../src/payment/zaprite/client.rs | 181 +++++++++++++ .../src/payment/zaprite/config.rs | 64 +++++ licensing-service/src/payment/zaprite/mod.rs | 48 ++++ .../src/payment/zaprite/provider.rs | 245 ++++++++++++++++++ licensing-service/tests/api.rs | 111 ++++++++ startos/actions/configureZaprite.ts | 199 ++++++++++++++ startos/actions/index.ts | 7 +- 12 files changed, 1130 insertions(+), 6 deletions(-) create mode 100644 licensing-service/migrations/0012_zaprite_config.sql create mode 100644 licensing-service/src/api/zaprite_authorize.rs create mode 100644 licensing-service/src/payment/zaprite/client.rs create mode 100644 licensing-service/src/payment/zaprite/config.rs create mode 100644 licensing-service/src/payment/zaprite/mod.rs create mode 100644 licensing-service/src/payment/zaprite/provider.rs create mode 100644 startos/actions/configureZaprite.ts diff --git a/licensing-service/migrations/0012_zaprite_config.sql b/licensing-service/migrations/0012_zaprite_config.sql new file mode 100644 index 0000000..20132cc --- /dev/null +++ b/licensing-service/migrations/0012_zaprite_config.sql @@ -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 +); diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index 01220fe..8fb4d32 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -73,6 +73,7 @@ pub mod community; pub mod db_info; pub mod rates_admin; pub mod recover; +pub mod zaprite_authorize; pub mod webhook; pub mod webhook_deliveries; pub mod webhook_endpoints; @@ -228,6 +229,28 @@ pub fn router(state: AppState) -> Router { "/v1/admin/btcpay/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/:id", diff --git a/licensing-service/src/api/zaprite_authorize.rs b/licensing-service/src/api/zaprite_authorize.rs new file mode 100644 index 0000000..0ae8693 --- /dev/null +++ b/licensing-service/src/api/zaprite_authorize.rs @@ -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, +} + +/// `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, + headers: HeaderMap, + Json(req): Json, +) -> AppResult> { + 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, + headers: HeaderMap, +) -> AppResult> { + 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, + headers: HeaderMap, +) -> AppResult> { + 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()), + }))) +} diff --git a/licensing-service/src/main.rs b/licensing-service/src/main.rs index df4deb7..723f56f 100644 --- a/licensing-service/src/main.rs +++ b/licensing-service/src/main.rs @@ -58,16 +58,27 @@ async fn main() -> anyhow::Result<()> { ); // --- payment provider (may be None until operator connects) --- - let provider: Option> = - load_btcpay_provider(&pool, &cfg).await.map(|p| { + // Resolution order: BTCPay first (the original / default), then + // 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> = { + if let Some(p) = load_btcpay_provider(&pool, &cfg).await { let arc: Arc = Arc::new(p); - arc - }); + Some(arc) + } else if let Some(p) = load_zaprite_provider(&pool).await { + let arc: Arc = Arc::new(p); + Some(arc) + } else { + None + } + }; match &provider { Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"), None => tracing::warn!( "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 } + +/// 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 { + 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 +} diff --git a/licensing-service/src/payment/mod.rs b/licensing-service/src/payment/mod.rs index c2d7da2..d7963ec 100644 --- a/licensing-service/src/payment/mod.rs +++ b/licensing-service/src/payment/mod.rs @@ -38,6 +38,7 @@ use serde::{Deserialize, Serialize}; use std::any::Any; pub mod btcpay; +pub mod zaprite; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] diff --git a/licensing-service/src/payment/zaprite/client.rs b/licensing-service/src/payment/zaprite/client.rs new file mode 100644 index 0000000..248bfc3 --- /dev/null +++ b/licensing-service/src/payment/zaprite/client.rs @@ -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, + /// `{ 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, + /// `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, +} + +impl ZapriteClient { + pub fn new(base_url: impl Into, api_key: impl Into) -> 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 { + 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 { + 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 { + 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 { + 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}" + )) + } +} diff --git a/licensing-service/src/payment/zaprite/config.rs b/licensing-service/src/payment/zaprite/config.rs new file mode 100644 index 0000000..c39e690 --- /dev/null +++ b/licensing-service/src/payment/zaprite/config.rs @@ -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, +} + +pub async fn load(pool: &SqlitePool) -> Result> { + 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(()) +} diff --git a/licensing-service/src/payment/zaprite/mod.rs b/licensing-service/src/payment/zaprite/mod.rs new file mode 100644 index 0000000..a148ca5 --- /dev/null +++ b/licensing-service/src/payment/zaprite/mod.rs @@ -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//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; diff --git a/licensing-service/src/payment/zaprite/provider.rs b/licensing-service/src/payment/zaprite/provider.rs new file mode 100644 index 0000000..1f1eca0 --- /dev/null +++ b/licensing-service/src/payment/zaprite/provider.rs @@ -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 { + // 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 { + 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 + // → 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 { + 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 { + 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) +} diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 100c328..4acf264 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -1204,6 +1204,117 @@ async fn paid_purchase_in_usd_records_listed_currency_and_rate() { 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 /// chain. Locks in the test-mode + maintenance-window contract that /// other phases (invoice rate recording, buy-page rendering) rely on. diff --git a/startos/actions/configureZaprite.ts b/startos/actions/configureZaprite.ts new file mode 100644 index 0000000..23d490c --- /dev/null +++ b/startos/actions/configureZaprite.ts @@ -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 +// /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` + + `/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, + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts index 0cd6830..cf4179d 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -21,6 +21,7 @@ import { sdk } from '../sdk' import { activateLicense, showLicenseStatus } from './activateLicense' import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay' +import { configureZaprite, disconnectZaprite, zapriteStatus } from './configureZaprite' import { setOperatorName } from './setOperatorName' import { setWebUiPassword } from './setWebUiPassword' import { showCredentials } from './showCredentials' @@ -29,10 +30,14 @@ export const actions = sdk.Actions.of() // General .addAction(setOperatorName) .addAction(setWebUiPassword) - // BTCPay setup + // BTCPay setup (Bitcoin-only payments via your own BTCPay Server) .addAction(configureBtcpay) .addAction(btcpayStatus) .addAction(disconnectBtcpay) + // Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker) + .addAction(configureZaprite) + .addAction(zapriteStatus) + .addAction(disconnectZaprite) // Keysat self-license (Keysat-licenses-Keysat) .addAction(activateLicense) .addAction(showLicenseStatus)