//! HTTP API surface. //! //! Route layout (v1): //! //! | Method | Path | Purpose | //! |--------|----------------------------------------|---------------------------------------------| //! | GET | `/` | service info + public key | //! | GET | `/healthz` | health check | //! | GET | `/thank-you` | post-payment landing (BTCPay redirect tgt) | //! | GET | `/admin/` | embedded admin web UI (SPA, client-gated) | //! | GET | `/admin/` | static assets for the embedded admin UI | //! | GET | `/v1/pubkey` | PEM-encoded Ed25519 public key | //! | GET | `/v1/products` | list active products | //! | GET | `/v1/products/:slug` | single product | //! | POST | `/v1/purchase` | start purchase, returns BTCPay URL | //! | GET | `/v1/purchase/:invoice_id` | poll purchase status + license if ready | //! | POST | `/v1/redeem` | redeem a 'free_license' code, no BTCPay | //! | POST | `/v1/validate` | validate a license key | //! | POST | `/v1/machines/activate` | explicit seat activation | //! | POST | `/v1/machines/heartbeat` | seat heartbeat | //! | POST | `/v1/machines/deactivate` | free a seat (client-initiated) | //! | POST | `/v1/btcpay/webhook` | BTCPay webhook landing | //! | Admin endpoints require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY` | //! | POST | `/v1/admin/products` | create product | //! | PATCH | `/v1/admin/products/:id/active` | activate / deactivate | //! | POST | `/v1/admin/licenses` | manually issue license (comp/dev) | //! | GET | `/v1/admin/licenses` | list licenses by product | //! | GET | `/v1/admin/licenses/search` | search by email / npub / invoice | //! | GET | `/v1/admin/licenses/summary` | aggregate counts (total/active/24h/7d) | //! | GET | `/v1/admin/revenue/summary` | lifetime / 30d / 7d / 24h sats earned | //! | POST | `/v1/admin/licenses/:id/revoke` | revoke a license | //! | POST | `/v1/admin/licenses/:id/suspend` | suspend (reversible) | //! | POST | `/v1/admin/licenses/:id/unsuspend` | unsuspend | //! | POST | `/v1/admin/policies` | create policy (license template) | //! | GET | `/v1/admin/policies` | list policies for product | //! | PATCH | `/v1/admin/policies/:id/active` | activate / deactivate policy | //! | GET | `/v1/admin/machines` | list machines for a license | //! | POST | `/v1/admin/machines/:id/deactivate` | force-kick a machine | //! | POST | `/v1/admin/webhook-endpoints` | register webhook subscriber | //! | GET | `/v1/admin/webhook-endpoints` | list webhook subscribers | //! | PATCH | `/v1/admin/webhook-endpoints/:id/active` | enable/disable | //! | DELETE | `/v1/admin/webhook-endpoints/:id` | delete webhook subscriber | //! | POST | `/v1/admin/discount-codes` | create discount / referral code | //! | GET | `/v1/admin/discount-codes` | list discount codes | //! | GET | `/v1/admin/discount-codes/:id` | one code with redemption history | //! | PATCH | `/v1/admin/discount-codes/:id/active` | enable / disable code | //! | PATCH | `/v1/admin/discount-codes/:id` | edit amount / max_uses / expires / desc | //! | DELETE | `/v1/admin/discount-codes/:id` | hard-delete (refused if redeemed) | //! | GET | `/v1/discount-codes/preview` | PUBLIC: preview discount on a product | //! | GET | `/v1/admin/audit` | list audit log entries | //! | POST | `/admin/login` | PUBLIC: web UI password login (sets cookie) | //! | POST | `/admin/logout` | clear session cookie | //! | GET | `/admin/login/status` | PUBLIC: {has_password, logged_in} | //! | POST | `/v1/admin/web-password` | admin-only: set/rotate web UI password | pub mod admin; pub mod admin_ui; pub mod auth; pub mod btcpay_authorize; pub mod discount_codes; pub mod machines; pub mod policies; pub mod products; pub mod purchase; pub mod buy_page; pub mod issuer_key; pub mod redeem; pub mod self_license; pub mod session_layer; pub mod tier; pub mod validate; pub mod community; pub mod db_info; pub mod recover; pub mod webhook; pub mod webhook_deliveries; pub mod webhook_endpoints; use crate::btcpay::client::BtcpayClient; use crate::config::Config; use crate::crypto::keys::ServerKeypair; use crate::error::{AppError, AppResult}; use axum::{ extract::FromRef, routing::{get, patch, post}, Json, Router, }; use serde_json::json; use sqlx::SqlitePool; use std::sync::Arc; use tokio::sync::RwLock; #[derive(Clone)] pub struct AppState { pub db: SqlitePool, pub keypair: Arc, /// Active payment provider (BTCPay today, Zaprite eventually). /// `None` until the operator completes a connect flow. Stored as /// `Arc` so call sites get cheap clones; swapped under a /// write lock when the operator runs Connect / Disconnect. pub payment: Arc>>>, pub config: Arc, /// Keysat-licenses-Keysat tier. Read at boot, swapped when the /// operator activates a fresh license via the admin endpoint. pub self_tier: Arc>, } impl AppState { /// Provider-agnostic accessor. New code should use this; legacy /// `btcpay_client()` / `btcpay_webhook_secret()` accessors remain /// for v0.2 compat and will retire as call sites migrate in v0.3. pub async fn payment_provider( &self, ) -> AppResult> { let guard = self.payment.read().await; guard .as_ref() .cloned() .ok_or(AppError::BtcpayNotConfigured) } /// Compat: returns the BTCPay-specific HTTP client, by clone, when /// the active provider is BTCPay. Falls back to /// `BtcpayNotConfigured` either when no provider is connected OR /// when the active provider isn't BTCPay (so Zaprite-only operators /// in v0.3 will get a clean error from BTCPay-specific code paths /// that haven't been migrated yet). pub async fn btcpay_client(&self) -> AppResult { let guard = self.payment.read().await; let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?; provider .as_any() .downcast_ref::() .map(|p| p.client().clone()) .ok_or(AppError::BtcpayNotConfigured) } /// Compat: returns the BTCPay HMAC webhook secret. See /// `btcpay_client()` for compat-error semantics. pub async fn btcpay_webhook_secret(&self) -> AppResult { let guard = self.payment.read().await; let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?; provider .as_any() .downcast_ref::() .map(|p| p.webhook_secret().to_string()) .ok_or(AppError::BtcpayNotConfigured) } /// Swap the active payment provider. Called by `btcpay_authorize` /// (and, later, `zaprite_authorize`). pub async fn set_payment_provider( &self, provider: Arc, ) { let mut guard = self.payment.write().await; *guard = Some(provider); } /// Clear the active payment provider (Disconnect flow). pub async fn clear_payment_provider(&self) { let mut guard = self.payment.write().await; *guard = None; } } impl FromRef for SqlitePool { fn from_ref(app: &AppState) -> Self { app.db.clone() } } pub fn router(state: AppState) -> Router { Router::new() .route("/", get(root)) .route("/healthz", get(healthz)) .route("/thank-you", get(thank_you)) // Public buyer-facing purchase page. Server-renders an HTML // page for a given product slug; the inlined form POSTs to // /v1/purchase and redirects to BTCPay checkout. .route("/buy/:slug", get(buy_page::render)) // Admin web UI — embedded into the binary at compile time via // rust-embed (see api/admin_ui.rs). The HTML page itself is // public; the SPA gates access client-side using the admin API // key, which is enforced server-side on every /v1/admin/* // call. .route("/admin", get(admin_ui::admin_root_redirect)) .route("/admin/", get(admin_ui::admin_index)) .route("/admin/*path", get(admin_ui::admin_asset)) .route("/v1/pubkey", get(pubkey)) .route("/v1/products", get(products::list)) .route("/v1/products/:slug", get(products::get)) .route("/v1/purchase", post(purchase::start)) .route("/v1/purchase/:invoice_id", get(purchase::status)) .route("/v1/redeem", post(redeem::redeem)) .route("/v1/validate", post(validate::validate)) // Buyer self-service recovery (lost key → re-derive from // settled invoice + buyer email). .route("/recover", get(recover::page)) .route("/v1/recover", post(recover::recover)) // Client-facing machine endpoints. .route("/v1/machines/activate", post(machines::activate)) .route("/v1/machines/heartbeat", post(machines::heartbeat)) .route("/v1/machines/deactivate", post(machines::deactivate)) .route("/v1/btcpay/webhook", post(webhook::handle)) .route( "/v1/admin/btcpay/connect", post(btcpay_authorize::start_connect), ) .route( "/v1/btcpay/authorize/callback", post(btcpay_authorize::callback).get(btcpay_authorize::callback_get), ) .route( "/v1/admin/btcpay/status", get(btcpay_authorize::status), ) .route( "/v1/admin/btcpay/disconnect", post(btcpay_authorize::disconnect), ) .route( "/v1/admin/btcpay/payment-methods", get(btcpay_authorize::payment_methods), ) .route("/v1/admin/products", post(admin::create_product)) .route( "/v1/admin/products/:id", patch(admin::update_product).delete(admin::delete_product), ) .route( "/v1/admin/products/:id/active", patch(admin::set_product_active), ) // Both GET (list) and POST (issue) on the same path — must be chained // onto a single MethodRouter, because axum's Router::route replaces. .route( "/v1/admin/licenses", get(admin::list_licenses).post(admin::issue_license), ) .route( "/v1/admin/licenses/search", get(admin::search_licenses), ) .route( "/v1/admin/licenses/summary", get(admin::licenses_summary), ) .route( "/v1/admin/licenses/counts", get(admin::license_counts), ) .route( "/v1/admin/revenue/summary", get(admin::revenue_summary), ) .route( "/v1/admin/licenses/:id/revoke", post(admin::revoke_license), ) .route( "/v1/admin/licenses/:id/suspend", post(admin::suspend_license), ) .route( "/v1/admin/licenses/:id/unsuspend", post(admin::unsuspend_license), ) // Policies (license templates). .route( "/v1/admin/policies", get(policies::list).post(policies::create), ) .route( "/v1/admin/policies/:id", patch(policies::update).delete(policies::delete), ) .route( "/v1/admin/policies/:id/active", patch(policies::set_active), ) .route( "/v1/admin/policies/:id/public", patch(policies::set_public), ) .route( "/v1/admin/policies/:id/tip", patch(policies::set_tip), ) // Public tier listing — drives the /buy/ tier picker. .route( "/v1/products/:slug/policies", get(policies::list_public_policies), ) .route("/v1/admin/tips", get(policies::list_tips)) // Machines (admin views). .route("/v1/admin/machines", get(machines::admin_list)) .route( "/v1/admin/machines/:id/deactivate", post(machines::admin_deactivate), ) // Webhook subscribers. .route( "/v1/admin/webhook-endpoints", get(webhook_endpoints::list).post(webhook_endpoints::create), ) .route( "/v1/admin/webhook-endpoints/:id/active", patch(webhook_endpoints::set_active), ) .route( "/v1/admin/webhook-endpoints/:id", axum::routing::delete(webhook_endpoints::delete), ) // Webhook delivery history (the dead-letter inspection + // manual-retry surface; see webhook_deliveries.rs for why). .route( "/v1/admin/webhook-deliveries", get(webhook_deliveries::list), ) .route( "/v1/admin/webhook-deliveries/:id/retry", post(webhook_deliveries::retry), ) // Database health snapshot — operator-facing sanity check // against the catastrophic-loss risk; see db_info.rs. .route("/v1/admin/db-info", get(db_info::get)) // Opt-in community analytics. Off by default; toggling on // requires the operator to confirm a collector URL. .route( "/v1/admin/community-analytics", get(community::get).post(community::set), ) .route( "/v1/admin/community-analytics/reset", post(community::reset), ) // Discount / referral codes. .route( "/v1/admin/discount-codes", get(discount_codes::list).post(discount_codes::create), ) .route( "/v1/admin/discount-codes/:id", get(discount_codes::get_one) .patch(discount_codes::update) .delete(discount_codes::delete), ) .route( "/v1/admin/discount-codes/:id/active", patch(discount_codes::set_active), ) // Public preview — buyer hits this from the buy page when they // click Apply on a discount code. Returns kind + computed // discounted price, doesn't consume a redemption slot. .route( "/v1/discount-codes/preview", get(discount_codes::preview), ) // Audit log. .route("/v1/admin/audit", get(admin::list_audit)) // Live-mutable settings. .route( "/v1/admin/settings/operator-name", get(admin::get_operator_name).post(admin::set_operator_name), ) // Keysat self-license (Keysat-licenses-Keysat). .route( "/v1/admin/self-license", get(self_license::status).post(self_license::activate), ) // Issuer-key import — admin-only, master-bootstrap path. No // StartOS Action surface; documented in MASTER_KEYPAIR_PROCEDURE.md. .route("/v1/admin/import-issuer-key", post(issuer_key::import)) // Public read of the issuer's signing public key — used by the // admin Overview "Embed your public key" tip and by SDK consumers. .route("/v1/issuer/public-key", get(issuer_key::public)) // Tier model — drives the admin sidebar's persistent upgrade banner. .route("/v1/admin/tier", get(tier::admin_status)) // Web-UI password auth (v0.1.0:28+). .route("/admin/login", post(auth::login)) .route("/admin/logout", post(auth::logout)) .route("/admin/login/status", get(auth::login_status)) .route("/v1/admin/web-password", post(auth::set_password)) // Bridge cookie-based sessions onto the existing API-key require_admin // guard. Has to be the last layer so it runs first (axum applies // layers in reverse-of-declaration order). .layer(axum::middleware::from_fn_with_state( state.clone(), session_layer::session_to_bearer, )) .with_state(state) } async fn root( axum::extract::State(state): axum::extract::State, ) -> Json { // Live-read the operator name from the settings table so admin // updates take effect without a daemon restart. Falls back to the // env-var-loaded config if the DB row hasn't been set yet (fresh // installs, or installs that pre-date this feature). let operator = match crate::db::repo::settings_get( &state.db, crate::api::admin::SETTING_OPERATOR_NAME, ) .await { Ok(Some(v)) => Some(v), _ => state.config.operator_name.clone(), }; Json(json!({ "service": "keysat", "version": env!("CARGO_PKG_VERSION"), "operator": operator, "public_key_pem": state.keypair.public_key_pem, "key_algorithm": "ed25519", "key_format_version": crate::crypto::KEY_VERSION, })) } async fn healthz() -> Json { Json(json!({ "ok": true })) } /// HTML "thank you" landing page that BTCPay redirects buyers to after a /// settled invoice. Reads `?invoice_id=` from the query string, /// renders a Keysat-branded polling page that calls /// /v1/purchase/ every few seconds until the response /// includes a `license_key`, then renders the license inline in a /// certificate-style card with a Copy button. Same visual language /// as the buy page's free-license success state. async fn thank_you( axum::extract::State(state): axum::extract::State, axum::extract::Query(params): axum::extract::Query>, ) -> axum::response::Html { let invoice_id = params.get("invoice_id").cloned().unwrap_or_default(); let invoice_id_safe = html_escape(&invoice_id); let invoice_id_json = serde_json::to_string(&invoice_id).unwrap_or_else(|_| "\"\"".into()); // Live-read operator_name from the settings table; fall back to the // env-var config; final fallback to a neutral brand name. let live = crate::db::repo::settings_get( &state.db, crate::api::admin::SETTING_OPERATOR_NAME, ) .await .ok() .flatten(); let operator_str = live .as_deref() .or(state.config.operator_name.as_deref()) .unwrap_or("Keysat"); let operator = html_escape(operator_str); let body = format!( r#" Payment received — {operator}
Keysat Sold by {operator}
Payment received

Issuing your license…

Your Bitcoin payment was received. We’re waiting for it to settle on the network and for the license to be signed. This usually takes under a minute once the next block confirms.

— Awaiting confirmation —

Hang tight.

This page will refresh automatically when your license is ready. Safe to bookmark this URL and come back later — your license will be here.

checking status…
— License issued —

You’re licensed.

Your signed license is below. Save it before closing this tab.

License key
Save this somewhere safe. The key is signed at issue time and verifies offline against the seller’s public key. You don’t need to come back here.
Powered by Keysat · Bitcoin-paid software licensing
"# ); axum::response::Html(body) } /// Minimal HTML escape for the operator name. Keeps this module dependency-free. fn html_escape(s: &str) -> String { s.chars() .map(|c| match c { '&' => "&".to_string(), '<' => "<".to_string(), '>' => ">".to_string(), '"' => """.to_string(), '\'' => "'".to_string(), _ => c.to_string(), }) .collect() } async fn pubkey( axum::extract::State(state): axum::extract::State, ) -> Json { Json(json!({ "algorithm": "ed25519", "public_key_pem": state.keypair.public_key_pem, })) }