04e0dcd591
Lays the schema + types + resolution layer for the merchant-profile-aware
multi-provider model documented in plans/multi-provider-payment-model.md.
Does NOT yet migrate any existing call site — legacy `state.payment_provider()`
and the singleton config tables continue to work via deprecation shims so
the daemon keeps running unchanged on this checkpoint.
This commit is intentionally a WIP foundation, not a shippable release —
no version bump, no release notes, no admin UI, no call-site migration.
A follow-up cycle ports purchase / subscriptions / reconcile / upgrade /
tipping to the new resolution layer, rebuilds the BTCPay + Zaprite connect
flows around merchant_profile_id, refactors webhook URLs to
/v1/{kind}/webhook/{provider_id}, ships the Merchant Profiles admin UI
section, wires the tier-cap, and bumps to :52 with the one-way migration
release notes.
What landed:
migrations/0020_merchant_profiles.sql
Full schema + data port + DROP of the singleton tables. Creates
merchant_profiles, payment_providers (FK to profile, unique per
(profile, kind)), merchant_profile_rail_preferences (tie-breaker
when a profile has 2 providers serving the same rail). Adds
merchant_profile_id to products + (merchant_profile_id, payment_provider_id)
to subscriptions for the snapshot-on-create semantics. Ports
btcpay_config + zaprite_config + active_payment_provider setting
into the new tables, then drops them. Master operator post-migration
step: update the Zaprite webhook URL on the Zaprite dashboard to
the new /v1/zaprite/webhook/{provider-id} form (or click Reconnect
Zaprite in the new UI once it ships).
src/merchant_profiles.rs (new module)
MerchantProfile struct + NewMerchantProfile + MerchantProfileUpdate
input types. Business-logic CRUD helpers: create, get, get_default,
require_default, list, update, set_default, delete, for_product.
Delete refuses if products or active subs are attached or if it's
the default profile. Tier-cap check stubbed with a TODO for the
next chunk's tier.rs wire-up.
src/db/repo.rs (+469 lines)
Repo helpers: create/get_by_id/get_default/get_for_product/list/
update/set_default/delete for merchant_profiles + count helpers
for products/active_subscriptions per profile. PaymentProviderRow
struct + create/get/list_for_profile/list_all/delete. RailPreference
struct + list/set/clear helpers. update_merchant_profile builds a
dynamic SET clause so partial updates don't clobber fields the
caller didn't touch.
src/payment/mod.rs
Rail enum (Lightning / Onchain / Card) + ProviderKind::parse +
rails_for_kind static mapping. build_provider(row, public_base) ->
Arc<dyn PaymentProvider> factory that dispatches on kind to construct
a typed BtcpayProvider or ZapriteProvider from a payment_providers
row. PaymentProvider trait gains a default served_rails() impl
returning rails_for_kind(self.kind()).
Deprecation shims: SETTING_ACTIVE_PROVIDER constant +
read_active_provider_preference + write_active_provider_preference
stay callable so btcpay_authorize/zaprite_authorize/main.rs/the
thank-you page still build. read_active_provider_preference now
reads from the new payment_providers table (returns the kind of
the first provider attached to the default profile), falling back
to the legacy settings-table read pre-migration. write_* is a no-op.
Each shim has a #[deprecated] attribute so the build surfaces
exactly which call sites still need porting (lit up in the
follow-up cycle's TODO).
src/api/mod.rs (AppState)
New methods alongside the existing payment_provider() shim:
- payment_provider_by_id(id) — looks up a row, builds the provider
- merchant_profile_for_product(product_id) — resolves via products.merchant_profile_id, falls back to default
- resolve_provider_for_profile_rail(profile_id, rail) —
preference table -> single candidate -> deterministic earliest-
connected with WARN. Returns (row, Arc<dyn PaymentProvider>).
- resolve_provider_for_product_rail(product_id, rail) — convenience
wrapping the previous two.
src/lib.rs
Registers the new merchant_profiles module.
Build state: cargo check passes. Only warnings are the pre-existing
unused-import in recover.rs and the deprecation lint firing on the
five legacy call sites enumerated in the WIP plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1123 lines
49 KiB
Rust
1123 lines
49 KiB
Rust
//! 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/<path>` | 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 api_keys;
|
||
pub mod auth;
|
||
pub mod openapi;
|
||
pub mod btcpay_authorize;
|
||
pub mod discount_codes;
|
||
pub mod machines;
|
||
pub mod policies;
|
||
pub mod products;
|
||
pub mod purchase;
|
||
pub mod subscriptions;
|
||
pub mod upgrade;
|
||
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 payment_provider;
|
||
pub mod rates_admin;
|
||
pub mod recover;
|
||
pub mod zaprite_authorize;
|
||
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;
|
||
use tower_http::cors::{Any, CorsLayer};
|
||
|
||
#[derive(Clone)]
|
||
pub struct AppState {
|
||
pub db: SqlitePool,
|
||
pub keypair: Arc<ServerKeypair>,
|
||
/// Active payment provider (BTCPay today, Zaprite eventually).
|
||
/// `None` until the operator completes a connect flow. Stored as
|
||
/// `Arc<dyn ...>` so call sites get cheap clones; swapped under a
|
||
/// write lock when the operator runs Connect / Disconnect.
|
||
pub payment: Arc<RwLock<Option<Arc<dyn crate::payment::PaymentProvider>>>>,
|
||
pub config: Arc<Config>,
|
||
/// Keysat-licenses-Keysat tier. Read at boot, swapped when the
|
||
/// operator activates a fresh license via the admin endpoint.
|
||
pub self_tier: Arc<RwLock<crate::license_self::Tier>>,
|
||
/// BTC/fiat rate cache for multi-currency products. See
|
||
/// src/rates.rs. Process-global so cached rates aren't refetched
|
||
/// per-request.
|
||
pub rates: Arc<crate::rates::RateCache>,
|
||
}
|
||
|
||
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<Arc<dyn crate::payment::PaymentProvider>> {
|
||
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<BtcpayClient> {
|
||
let guard = self.payment.read().await;
|
||
let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?;
|
||
provider
|
||
.as_any()
|
||
.downcast_ref::<crate::payment::btcpay::BtcpayProvider>()
|
||
.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<String> {
|
||
let guard = self.payment.read().await;
|
||
let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?;
|
||
provider
|
||
.as_any()
|
||
.downcast_ref::<crate::payment::btcpay::BtcpayProvider>()
|
||
.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<dyn crate::payment::PaymentProvider>,
|
||
) {
|
||
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;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Merchant-profile-aware resolution layer (migration 0020+)
|
||
// ---------------------------------------------------------------------
|
||
//
|
||
// The legacy `payment_provider()` / `set_payment_provider()` accessors
|
||
// above continue to work as a "default provider for the default
|
||
// profile" compatibility shim during the multi-provider transition.
|
||
// New call sites should use one of the methods below instead.
|
||
|
||
/// Look up a payment provider by its row id. Reads the row from the
|
||
/// DB, instantiates a typed `PaymentProvider` impl via
|
||
/// `payment::build_provider`. Not cached today — the caller is
|
||
/// usually invoking it once per request lifecycle so the cost is
|
||
/// nil. Add a cache here when profiling says we need one.
|
||
pub async fn payment_provider_by_id(
|
||
&self,
|
||
provider_id: &str,
|
||
) -> AppResult<Arc<dyn crate::payment::PaymentProvider>> {
|
||
let row = crate::db::repo::get_payment_provider_by_id(&self.db, provider_id)
|
||
.await?
|
||
.ok_or_else(|| {
|
||
AppError::NotFound(format!("payment provider {provider_id}"))
|
||
})?;
|
||
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
|
||
.map_err(AppError::Internal)
|
||
}
|
||
|
||
/// Resolve the merchant profile a product belongs to. Falls back to
|
||
/// the default profile if the product has no `merchant_profile_id`
|
||
/// set (defensive — shouldn't happen post-migration, but handles
|
||
/// any rows that slip through).
|
||
pub async fn merchant_profile_for_product(
|
||
&self,
|
||
product_id: &str,
|
||
) -> AppResult<crate::merchant_profiles::MerchantProfile> {
|
||
crate::merchant_profiles::for_product(self, product_id).await
|
||
}
|
||
|
||
/// Pick the provider on `profile_id` that serves the given `rail`.
|
||
/// Resolution order:
|
||
/// 1. Honor `merchant_profile_rail_preferences` if the operator
|
||
/// set an explicit preference for this (profile, rail) pair.
|
||
/// 2. If exactly one attached provider serves the rail, use it.
|
||
/// 3. If multiple serve the rail and no preference is set, use
|
||
/// the earliest-`connected_at` one (deterministic) and log a
|
||
/// warning so the operator knows to set an explicit preference.
|
||
/// 4. If no attached provider serves the rail, return
|
||
/// `AppError::BadRequest` — caller should treat this as
|
||
/// "buyer's pick isn't available for this merchant."
|
||
pub async fn resolve_provider_for_profile_rail(
|
||
&self,
|
||
profile_id: &str,
|
||
rail: crate::payment::Rail,
|
||
) -> AppResult<(crate::db::repo::PaymentProviderRow, Arc<dyn crate::payment::PaymentProvider>)> {
|
||
// 1. Check the explicit preference table first.
|
||
let preferences = crate::db::repo::list_rail_preferences_for_profile(&self.db, profile_id).await?;
|
||
if let Some(pref) = preferences.iter().find(|p| p.rail == rail.as_str()) {
|
||
let row = crate::db::repo::get_payment_provider_by_id(&self.db, &pref.payment_provider_id)
|
||
.await?
|
||
.ok_or_else(|| {
|
||
AppError::Internal(anyhow::anyhow!(
|
||
"rail preference points at missing provider {}",
|
||
pref.payment_provider_id
|
||
))
|
||
})?;
|
||
let provider =
|
||
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
|
||
.map_err(AppError::Internal)?;
|
||
return Ok((row, provider));
|
||
}
|
||
|
||
// 2. + 3. No explicit preference — find providers on this profile
|
||
// whose kind serves the requested rail.
|
||
let providers = crate::db::repo::list_payment_providers_for_profile(&self.db, profile_id).await?;
|
||
let mut candidates: Vec<&crate::db::repo::PaymentProviderRow> = providers
|
||
.iter()
|
||
.filter(|row| {
|
||
crate::payment::ProviderKind::parse(&row.kind)
|
||
.map(|kind| crate::payment::rails_for_kind(kind).contains(&rail))
|
||
.unwrap_or(false)
|
||
})
|
||
.collect();
|
||
// Earliest-connected-first is already the order from list_payment_providers_for_profile
|
||
// (ORDER BY connected_at ASC), but be explicit for clarity.
|
||
candidates.sort_by(|a, b| a.connected_at.cmp(&b.connected_at));
|
||
|
||
match candidates.as_slice() {
|
||
[] => Err(AppError::BadRequest(format!(
|
||
"merchant profile {profile_id} has no provider that serves the '{}' rail. \
|
||
Connect one in the admin UI's Merchant Profiles page, or set a rail \
|
||
preference if multiple providers serve this rail.",
|
||
rail.as_str()
|
||
))),
|
||
[only] => {
|
||
let row = (*only).clone();
|
||
let provider =
|
||
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
|
||
.map_err(AppError::Internal)?;
|
||
Ok((row, provider))
|
||
}
|
||
[first, ..] => {
|
||
tracing::warn!(
|
||
profile_id = %profile_id,
|
||
rail = rail.as_str(),
|
||
chosen = %first.id,
|
||
candidate_count = candidates.len(),
|
||
"multiple providers serve this rail on the profile; using earliest-connected \
|
||
deterministically. Set an explicit rail preference in the admin UI to silence \
|
||
this warning."
|
||
);
|
||
let row = (*first).clone();
|
||
let provider =
|
||
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
|
||
.map_err(AppError::Internal)?;
|
||
Ok((row, provider))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Convenience for the most common purchase-flow case: given a
|
||
/// product id and a buyer-picked rail, resolve to (profile, provider
|
||
/// row, provider impl). Used by `purchase.rs` and `subscriptions.rs`
|
||
/// renewals.
|
||
pub async fn resolve_provider_for_product_rail(
|
||
&self,
|
||
product_id: &str,
|
||
rail: crate::payment::Rail,
|
||
) -> AppResult<(
|
||
crate::merchant_profiles::MerchantProfile,
|
||
crate::db::repo::PaymentProviderRow,
|
||
Arc<dyn crate::payment::PaymentProvider>,
|
||
)> {
|
||
let profile = self.merchant_profile_for_product(product_id).await?;
|
||
let (row, provider) = self.resolve_provider_for_profile_rail(&profile.id, rail).await?;
|
||
Ok((profile, row, provider))
|
||
}
|
||
}
|
||
|
||
impl FromRef<AppState> 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),
|
||
)
|
||
// 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),
|
||
)
|
||
// Provider-agnostic active-payment-provider control.
|
||
// Operators with both BTCPay and Zaprite configured can flip
|
||
// the active one without re-running Connect.
|
||
.route(
|
||
"/v1/admin/payment-provider/status",
|
||
get(payment_provider::status),
|
||
)
|
||
.route(
|
||
"/v1/admin/payment-provider/activate",
|
||
post(payment_provider::activate),
|
||
)
|
||
// 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",
|
||
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/archived",
|
||
patch(policies::set_archived),
|
||
)
|
||
.route(
|
||
"/v1/admin/policies/:id/tip",
|
||
patch(policies::set_tip),
|
||
)
|
||
// Public tier listing — drives the /buy/<slug> tier picker.
|
||
.route(
|
||
"/v1/products/:slug/policies",
|
||
get(policies::list_public_policies),
|
||
)
|
||
.route("/v1/admin/tips", get(policies::list_tips))
|
||
// Scoped API keys — additional credentials with bounded permissions.
|
||
// Master admin_api_key gates the management endpoints; the scoped
|
||
// keys themselves are accepted on endpoints that call require_scope.
|
||
.route(
|
||
"/v1/admin/api-keys",
|
||
get(api_keys::list).post(api_keys::create),
|
||
)
|
||
.route("/v1/admin/api-keys/:id", axum::routing::delete(api_keys::revoke))
|
||
// Subscriptions (recurring billing) — admin list + cancel.
|
||
.route(
|
||
"/v1/admin/subscriptions",
|
||
get(subscriptions::admin_list),
|
||
)
|
||
.route(
|
||
"/v1/admin/subscriptions/:id/cancel",
|
||
post(subscriptions::admin_cancel),
|
||
)
|
||
// Buyer self-service cancel — auth via license key in the body.
|
||
.route(
|
||
"/v1/subscriptions/cancel",
|
||
post(subscriptions::buyer_cancel),
|
||
)
|
||
// Tier upgrades (buyer self-service). Quote is read-only;
|
||
// start kicks off a payment for the prorated charge.
|
||
// Both auth via signed license_key in the body, same model
|
||
// as /v1/recover and /v1/subscriptions/cancel.
|
||
.route("/v1/upgrade-quote", post(upgrade::quote))
|
||
.route("/v1/upgrade", post(upgrade::start))
|
||
// Admin force-change: skip ladder rules, optional skip_payment
|
||
// for comp upgrades. Bears full audit trail.
|
||
.route(
|
||
"/v1/admin/licenses/:id/change-tier",
|
||
post(upgrade::admin_change),
|
||
)
|
||
// 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))
|
||
// BTC/fiat rate cache — operator-facing view of what the
|
||
// daemon would quote for fiat-priced products. See
|
||
// src/rates.rs for the source chain (Kraken → Coinbase
|
||
// → CoinGecko) and TTL caching semantics.
|
||
.route("/v1/admin/rates", get(rates_admin::get))
|
||
.route("/v1/admin/rates/refresh", post(rates_admin::refresh))
|
||
// 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),
|
||
)
|
||
// Manual self-tier refresh — re-reads live entitlements
|
||
// from the local DB. Operators hit this after a Change
|
||
// Tier to update the running daemon immediately instead of
|
||
// waiting for the hourly background refresher.
|
||
.route(
|
||
"/v1/admin/self-license/refresh",
|
||
post(self_license::refresh),
|
||
)
|
||
// 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))
|
||
// OpenAPI 3.1 spec — public, no auth. Drives agent discovery and
|
||
// SDK code generation. Curated subset of the full route surface;
|
||
// see crate::api::openapi for the inline definition.
|
||
.route("/v1/openapi.json", get(openapi::spec))
|
||
// 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. Layers apply in reverse-of-declaration order, so this runs
|
||
// AFTER the CorsLayer below (i.e. session-bridge is "inside" CORS).
|
||
.layer(axum::middleware::from_fn_with_state(
|
||
state.clone(),
|
||
session_layer::session_to_bearer,
|
||
))
|
||
// CORS. Declared last so it's the OUTERMOST layer at runtime,
|
||
// which means:
|
||
// (a) Preflight OPTIONS requests get answered directly by the
|
||
// CorsLayer and never hit the session-bridge or any handler.
|
||
// Without this, OPTIONS to /v1/admin/* would 401 because
|
||
// browsers don't send credentials on preflights.
|
||
// (b) `allow_credentials` is NOT enabled. That's deliberate —
|
||
// `Access-Control-Allow-Origin: *` combined with credentials
|
||
// is rejected by browsers, AND keeping it off means a hostile
|
||
// cross-origin page can't piggy-back on a logged-in admin
|
||
// session cookie. Bearer-token auth on /v1/admin/* still
|
||
// works (the agent / SDK supplies the token explicitly).
|
||
//
|
||
// Net effect: public endpoints like /v1/products/:slug/policies,
|
||
// /v1/openapi.json, /v1/validate, /v1/issuer/public-key can be
|
||
// called from any origin (docs.keysat.xyz, keysat.xyz, third-
|
||
// party tools). Admin endpoints stay closed without a token.
|
||
.layer(
|
||
CorsLayer::new()
|
||
.allow_origin(Any)
|
||
.allow_methods(Any)
|
||
.allow_headers(Any),
|
||
)
|
||
.with_state(state)
|
||
}
|
||
|
||
async fn root(
|
||
axum::extract::State(state): axum::extract::State<AppState>,
|
||
) -> Json<serde_json::Value> {
|
||
// 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<serde_json::Value> {
|
||
Json(json!({ "ok": true }))
|
||
}
|
||
|
||
/// HTML "thank you" landing page that BTCPay redirects buyers to after a
|
||
/// settled invoice. Reads `?invoice_id=<id>` from the query string,
|
||
/// renders a Keysat-branded polling page that calls
|
||
/// /v1/purchase/<invoice_id> 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<AppState>,
|
||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||
) -> axum::response::Html<String> {
|
||
let invoice_id = params.get("invoice_id").cloned().unwrap_or_default();
|
||
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);
|
||
|
||
// Provider-aware confirmation copy. BTCPay is Bitcoin-only (Lightning
|
||
// + on-chain); Zaprite brokers card payments too (Stripe / etc.) plus
|
||
// Bitcoin. The lede and the polling-status copy should reflect which
|
||
// payment rails are actually in play so a buyer who paid by card
|
||
// doesn't see "your Bitcoin payment was received" while their Stripe
|
||
// transaction shows up in the operator's dashboard.
|
||
//
|
||
// Today this reads `SETTING_ACTIVE_PROVIDER` (the singleton model).
|
||
// When the multi-provider work lands, swap this for a lookup of the
|
||
// invoice's own `payment_provider_id` so the copy matches the rail
|
||
// that actually settled THIS purchase, not whatever's currently
|
||
// active on the daemon.
|
||
let provider_kind = crate::payment::read_active_provider_preference(&state.db).await;
|
||
let (lede_text, provider_kind_str) = match provider_kind {
|
||
Some(crate::payment::ProviderKind::Zaprite) => (
|
||
"Your payment was received. We\u{2019}re waiting for it to settle and \
|
||
for the license to be signed. Card payments confirm in seconds; \
|
||
Bitcoin Lightning also settles in seconds; on-chain Bitcoin typically \
|
||
settles in 10\u{2013}20 minutes (one block confirmation).",
|
||
"zaprite",
|
||
),
|
||
// BTCPay or unconfigured → original Bitcoin-only copy. Unconfigured
|
||
// is rare on this page (operator hit /thank-you without a provider
|
||
// connected) so we keep it Bitcoin-flavored rather than introducing
|
||
// a third "unknown" branch.
|
||
_ => (
|
||
"Your Bitcoin payment was received. We\u{2019}re waiting for it to settle \
|
||
and for the license to be signed. Lightning settles in seconds; on-chain \
|
||
typically settles in 10\u{2013}20 minutes (one block confirmation).",
|
||
"btcpay",
|
||
),
|
||
};
|
||
let provider_kind_json = serde_json::to_string(provider_kind_str)
|
||
.unwrap_or_else(|_| "\"btcpay\"".into());
|
||
let body = format!(
|
||
r#"<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Payment received — {operator}</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {{
|
||
--navy-950:#0E1F33; --navy-900:#142A47; --navy-800:#1E3A5F;
|
||
--cream-50:#FBF9F2; --cream-100:#F5F1E8; --cream-200:#EDE7D7;
|
||
--gold-700:#8A6F3D; --gold-500:#BFA068; --gold-400:#D4B985;
|
||
--ink-900:#0E1F33; --ink-700:#2C3E54; --ink-500:#5A6B7F;
|
||
--success:#2D7A5F; --success-bg:#E3F0EA;
|
||
--danger:#B23A3A; --danger-bg:#F4E0E0;
|
||
--border-1:rgba(14,31,51,0.12);
|
||
--border-2:rgba(14,31,51,0.20);
|
||
--font-display:'Manrope','Helvetica Neue',Arial,sans-serif;
|
||
--font-body:'Inter','Helvetica Neue',Arial,sans-serif;
|
||
--font-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;
|
||
--shadow-md:0 2px 4px rgba(14,31,51,0.06),0 4px 12px rgba(14,31,51,0.06);
|
||
}}
|
||
*{{box-sizing:border-box}} html,body{{margin:0;padding:0}}
|
||
body {{
|
||
font-family:var(--font-body); color:var(--ink-900);
|
||
background:var(--cream-100);
|
||
background-image:
|
||
radial-gradient(rgba(14,31,51,0.025) 1px, transparent 1px),
|
||
radial-gradient(rgba(138,111,61,0.022) 1px, transparent 1px);
|
||
background-size:3px 3px, 7px 7px;
|
||
-webkit-font-smoothing:antialiased; min-height:100vh;
|
||
}}
|
||
.topbar {{
|
||
background:rgba(245,241,232,0.85); backdrop-filter:blur(10px);
|
||
border-bottom:1px solid var(--border-1); padding:14px 24px;
|
||
}}
|
||
.topbar .inner {{
|
||
max-width:680px; margin:0 auto;
|
||
display:flex; align-items:center; gap:12px;
|
||
font-family:var(--font-display); font-weight:500; font-size:14px;
|
||
letter-spacing:0.28em; text-transform:uppercase; color:var(--navy-900);
|
||
}}
|
||
.topbar .operator {{
|
||
font-family:var(--font-body); font-size:12px;
|
||
letter-spacing:0.04em; text-transform:none;
|
||
color:var(--ink-500); margin-left:auto;
|
||
}}
|
||
.wrap {{ max-width:560px; margin:48px auto; padding:0 24px; }}
|
||
.eyebrow {{
|
||
font-size:11.5px; font-weight:700; letter-spacing:0.18em;
|
||
text-transform:uppercase; color:var(--gold-700); margin-bottom:14px;
|
||
display:inline-flex; align-items:center; gap:10px;
|
||
}}
|
||
.eyebrow::before {{ content:''; display:inline-block; width:28px; height:1px; background:var(--gold-500); }}
|
||
h1 {{
|
||
font-family:var(--font-display); font-weight:500; font-size:38px;
|
||
line-height:1.05; letter-spacing:-0.022em; color:var(--navy-950); margin:0 0 14px;
|
||
}}
|
||
.lede {{ font-size:16px; line-height:1.55; color:var(--ink-700); margin:0 0 28px; }}
|
||
.pending-card, .license-success, .error-card {{
|
||
background:var(--cream-50); border:1px solid var(--border-1);
|
||
border-radius:14px; box-shadow:var(--shadow-md);
|
||
padding:32px 32px 28px; position:relative;
|
||
}}
|
||
.license-success, .pending-card {{
|
||
box-shadow:0 0 0 1px var(--gold-500) inset, var(--shadow-md);
|
||
}}
|
||
.license-success::before, .license-success::after,
|
||
.pending-card::before, .pending-card::after {{
|
||
content:''; position:absolute; left:14px; right:14px;
|
||
height:1px; background:var(--gold-500); opacity:0.5;
|
||
}}
|
||
.license-success::before, .pending-card::before {{ top:14px; }}
|
||
.license-success::after, .pending-card::after {{ bottom:14px; }}
|
||
.stamp {{
|
||
font-size:10px; font-weight:700; letter-spacing:0.22em;
|
||
text-transform:uppercase; color:var(--gold-700);
|
||
text-align:center; margin-bottom:16px;
|
||
}}
|
||
.pending-card h2 {{
|
||
font-family:var(--font-display); font-weight:500; font-size:22px;
|
||
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em; text-align:center;
|
||
}}
|
||
.pending-card .sub, .license-success .sub {{
|
||
font-size:14px; color:var(--ink-500); text-align:center; margin:0 0 22px;
|
||
}}
|
||
.spinner {{
|
||
width:32px; height:32px; border-radius:50%;
|
||
border:3px solid var(--border-1); border-top-color:var(--gold-500);
|
||
animation:spin 1s linear infinite;
|
||
margin:18px auto 22px;
|
||
}}
|
||
@keyframes spin {{ to {{ transform:rotate(360deg); }} }}
|
||
.status-detail {{
|
||
font-family:var(--font-mono); font-size:12.5px;
|
||
background:var(--cream-100); border:1px solid var(--border-1);
|
||
border-radius:7px; padding:8px 12px;
|
||
color:var(--ink-700); text-align:center;
|
||
}}
|
||
.invoice-ref {{
|
||
margin-top:12px; padding:8px 12px;
|
||
font-family:var(--font-mono); font-size:11.5px;
|
||
color:var(--ink-500); text-align:center;
|
||
}}
|
||
.invoice-ref code {{
|
||
background:var(--cream-100); border:1px solid var(--border-1);
|
||
padding:1px 6px; border-radius:5px; color:var(--ink-700);
|
||
}}
|
||
.license-success h2 {{
|
||
font-family:var(--font-display); font-weight:500; font-size:22px;
|
||
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em; text-align:center;
|
||
}}
|
||
.field-label {{
|
||
font-size:11px; font-weight:600; letter-spacing:0.12em;
|
||
text-transform:uppercase; color:var(--ink-500); margin-bottom:6px;
|
||
}}
|
||
.key-box {{
|
||
background:var(--navy-950); color:var(--cream-50);
|
||
padding:14px 16px; border-radius:8px;
|
||
font-family:var(--font-mono); font-size:12.5px;
|
||
word-break:break-all; line-height:1.5;
|
||
display:flex; align-items:flex-start; gap:12px;
|
||
}}
|
||
.key-box .key-text {{ flex:1; }}
|
||
.key-box button {{
|
||
background:rgba(245,241,232,0.10); color:var(--cream-50);
|
||
border:0; padding:6px 10px; border-radius:6px;
|
||
font-family:var(--font-body); font-size:11.5px; cursor:pointer;
|
||
flex-shrink:0;
|
||
}}
|
||
.key-box button:hover {{ background:rgba(245,241,232,0.20); }}
|
||
.save-note {{
|
||
margin-top:14px; font-size:13px; color:var(--ink-700);
|
||
background:var(--cream-100); border:1px solid var(--border-1);
|
||
border-radius:8px; padding:10px 14px;
|
||
}}
|
||
.save-note strong {{ color:var(--navy-950); }}
|
||
.error-card {{
|
||
border-color:rgba(178,58,58,0.3); background:var(--danger-bg);
|
||
color:#8a2828; font-size:14px;
|
||
}}
|
||
.hide {{ display:none !important; }}
|
||
footer.kfooter {{
|
||
text-align:center; font-size:12px; color:var(--ink-500);
|
||
margin-top:48px; padding:18px;
|
||
}}
|
||
footer.kfooter a {{ color:var(--ink-500); text-decoration:none; }}
|
||
footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||
/* Mobile breakpoint — desktop-rhythm padding crowds 360-390px screens. */
|
||
@media (max-width:480px) {{
|
||
.topbar {{ padding:12px 16px; }}
|
||
.topbar .inner {{ font-size:13px; letter-spacing:0.22em; gap:8px; }}
|
||
.topbar .operator {{ font-size:11px; }}
|
||
.wrap {{ margin:24px auto; padding:0 16px; }}
|
||
h1 {{ font-size:clamp(26px, 7vw, 38px); }}
|
||
.lede {{ font-size:15px; margin:0 0 22px; }}
|
||
.pending-card, .license-success, .error-card {{ padding:22px 20px 20px; }}
|
||
.pending-card h2 {{ font-size:20px; }}
|
||
footer.kfooter {{ margin-top:32px; padding:14px; }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="topbar">
|
||
<div class="inner">
|
||
<span>Keysat</span>
|
||
<span class="operator">Sold by {operator}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wrap">
|
||
<div class="eyebrow">Payment received</div>
|
||
<h1 id="page-title">Issuing your license…</h1>
|
||
<p class="lede" id="page-lede">{lede_text}</p>
|
||
|
||
<!-- pending state (default): polling for the license -->
|
||
<div class="pending-card" id="pending-card">
|
||
<div class="stamp">— Awaiting confirmation —</div>
|
||
<h2>Hang tight.</h2>
|
||
<p class="sub">This page will refresh automatically when your license is ready. Safe to bookmark this URL and come back later — your license will be here.</p>
|
||
<div class="spinner" aria-hidden="true"></div>
|
||
<div class="status-detail" id="status-detail">checking status…</div>
|
||
<div class="invoice-ref" id="invoice-ref"></div>
|
||
</div>
|
||
|
||
<!-- success state: license card -->
|
||
<div class="license-success hide" id="license-success" role="region" aria-label="License issued">
|
||
<div class="stamp">— License issued —</div>
|
||
<h2>You’re licensed.</h2>
|
||
<p class="sub">Your signed license is below. Save it before closing this tab.</p>
|
||
<div class="field-label">License key</div>
|
||
<div class="key-box">
|
||
<span class="key-text" id="license-key-text">…</span>
|
||
<button id="license-key-copy">Copy</button>
|
||
</div>
|
||
<div class="save-note">
|
||
<strong>Save this somewhere safe.</strong> The key is signed at issue time and verifies offline against the seller’s public key. You don’t need to come back here.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- error state: invoice not found, or unrecoverable -->
|
||
<div class="error-card hide" id="error-card" role="alert">
|
||
<div id="error-msg">Something went wrong looking up this purchase.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="kfooter">
|
||
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> · Bitcoin-native self-hosted software licensing</span>
|
||
</footer>
|
||
|
||
<script>
|
||
(function() {{
|
||
const INVOICE_ID = {invoice_id_json};
|
||
// 'zaprite' | 'btcpay' — selects which payment-rail copy the
|
||
// polling status uses (Zaprite: card + Lightning + on-chain; BTCPay:
|
||
// Lightning + on-chain only).
|
||
const PROVIDER_KIND = {provider_kind_json};
|
||
if (!INVOICE_ID) {{
|
||
document.getElementById('pending-card').classList.add('hide');
|
||
document.getElementById('error-card').classList.remove('hide');
|
||
document.getElementById('error-msg').textContent = 'No invoice id supplied. Looking for your license? Check your email or contact the seller.';
|
||
return;
|
||
}}
|
||
|
||
const pendingCard = document.getElementById('pending-card');
|
||
const successCard = document.getElementById('license-success');
|
||
const errorCard = document.getElementById('error-card');
|
||
const statusDetail = document.getElementById('status-detail');
|
||
const keyText = document.getElementById('license-key-text');
|
||
const errorMsg = document.getElementById('error-msg');
|
||
const pageTitle = document.getElementById('page-title');
|
||
const pageLede = document.getElementById('page-lede');
|
||
const invoiceRef = document.getElementById('invoice-ref');
|
||
if (invoiceRef) {{
|
||
invoiceRef.innerHTML = 'Reference for support: <code>' +
|
||
INVOICE_ID.replace(/[<>&]/g, '') + '</code>';
|
||
}}
|
||
|
||
// Copy button.
|
||
document.getElementById('license-key-copy').addEventListener('click', async function() {{
|
||
try {{
|
||
await navigator.clipboard.writeText(keyText.textContent);
|
||
this.textContent = 'Copied';
|
||
setTimeout(() => {{ this.textContent = 'Copy'; }}, 1400);
|
||
}} catch (e) {{}}
|
||
}});
|
||
|
||
function showSuccess(licenseKey) {{
|
||
pendingCard.classList.add('hide');
|
||
errorCard.classList.add('hide');
|
||
keyText.textContent = licenseKey;
|
||
successCard.classList.remove('hide');
|
||
pageTitle.textContent = 'Your license is ready.';
|
||
pageLede.textContent = 'Save the key below — it verifies offline against the seller’s public key. You can close this tab when you’re done.';
|
||
}}
|
||
function showError(msg) {{
|
||
pendingCard.classList.add('hide');
|
||
successCard.classList.add('hide');
|
||
errorMsg.textContent = msg;
|
||
errorCard.classList.remove('hide');
|
||
pageTitle.textContent = 'Something went wrong.';
|
||
pageLede.textContent = 'See the message below for details.';
|
||
}}
|
||
|
||
// Adaptive polling: tight cadence for the first 2 minutes (most invoices
|
||
// settle within one block), then back off so a slow block + clearnet flake
|
||
// doesn't burn battery/data on the buyer's phone. URL is bookmark-friendly:
|
||
// a buyer can close this tab and return any time — polling resumes from
|
||
// wherever the invoice currently is.
|
||
let attempt = 0;
|
||
let elapsedMs = 0;
|
||
const TIGHT_MS = 3000; // 0–2 min → poll every 3s
|
||
const MED_MS = 10000; // 2–10 min → poll every 10s
|
||
const SLOW_MS = 30000; // 10–30 min→ poll every 30s
|
||
const TIGHT_DEADLINE = 2 * 60 * 1000;
|
||
const MED_DEADLINE = 10 * 60 * 1000;
|
||
const HARD_DEADLINE = 30 * 60 * 1000;
|
||
|
||
function nextDelay() {{
|
||
if (elapsedMs < TIGHT_DEADLINE) return TIGHT_MS;
|
||
if (elapsedMs < MED_DEADLINE) return MED_MS;
|
||
return SLOW_MS;
|
||
}}
|
||
|
||
function waitingCopy(status) {{
|
||
const min = Math.floor(elapsedMs / 60000);
|
||
const isZaprite = PROVIDER_KIND === 'zaprite';
|
||
if (status === 'pending' || status === 'processing') {{
|
||
if (min < 2) {{
|
||
return isZaprite
|
||
? 'invoice ' + status + ' — card payments confirm in seconds; Bitcoin Lightning in seconds; on-chain takes a block (~10 min).'
|
||
: 'invoice ' + status + ' — Lightning settles in seconds; on-chain takes a block (~10 min).';
|
||
}}
|
||
if (min < 10) {{
|
||
return isZaprite
|
||
? 'invoice ' + status + ' — waiting for confirmation. Card auth or on-chain Bitcoin can take a few minutes. Safe to leave this tab open or bookmark this URL.'
|
||
: 'invoice ' + status + ' — looks like an on-chain payment, waiting for block confirmation. Safe to leave this tab open or bookmark this URL.';
|
||
}}
|
||
return isZaprite
|
||
? 'invoice ' + status + ' — slow confirmation. Still polling. Bookmark this URL and refresh later if you close the tab.'
|
||
: 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.';
|
||
}}
|
||
return 'invoice status: ' + (status || 'pending');
|
||
}}
|
||
|
||
async function poll() {{
|
||
attempt++;
|
||
try {{
|
||
const r = await fetch('/v1/purchase/' + encodeURIComponent(INVOICE_ID));
|
||
if (r.status === 404) {{
|
||
return showError('Invoice not found. The link may have been mistyped.');
|
||
}}
|
||
if (!r.ok) {{
|
||
statusDetail.textContent = 'server returned HTTP ' + r.status + ' (will retry)';
|
||
return scheduleNext();
|
||
}}
|
||
const j = await r.json();
|
||
if (j.license_key) {{
|
||
return showSuccess(j.license_key);
|
||
}}
|
||
const status = j.status || 'pending';
|
||
statusDetail.textContent = waitingCopy(status);
|
||
if (status === 'expired' || status === 'invalid') {{
|
||
return showError('Payment was not completed (status: ' + status + '). If you sent funds, contact the seller and reference your invoice id above.');
|
||
}}
|
||
scheduleNext();
|
||
}} catch (err) {{
|
||
statusDetail.textContent = 'network error (retrying): ' + (err.message || err);
|
||
scheduleNext();
|
||
}}
|
||
}}
|
||
function scheduleNext() {{
|
||
if (elapsedMs >= HARD_DEADLINE) {{
|
||
statusDetail.textContent =
|
||
'still waiting after 30 minutes. Bookmark this URL and refresh in a few minutes — your license will appear automatically once the invoice settles. If you still see this in an hour, contact the seller and reference the invoice id at the top of this page.';
|
||
return;
|
||
}}
|
||
const d = nextDelay();
|
||
elapsedMs += d;
|
||
setTimeout(poll, d);
|
||
}}
|
||
poll();
|
||
}})();
|
||
</script>
|
||
|
||
</body>
|
||
</html>"#
|
||
);
|
||
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<AppState>,
|
||
) -> Json<serde_json::Value> {
|
||
Json(json!({
|
||
"algorithm": "ed25519",
|
||
"public_key_pem": state.keypair.public_key_pem,
|
||
}))
|
||
}
|