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

Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages,
discount codes, free-license redemption, Apply-discount UX,
self-licensing, and v0.1.0 release notes.
This commit is contained in:
Grant
2026-05-07 10:33:39 -05:00
parent 432250bffc
commit 6ac118ae70
90 changed files with 14896 additions and 524 deletions
+566
View File
@@ -0,0 +1,566 @@
//! Admin endpoints — all require `Authorization: Bearer <admin_api_key>`.
//! The operator uses these to manage products and issue/revoke licenses.
use crate::api::AppState;
use crate::crypto::{encode_key, sign_payload, LicensePayload, KEY_VERSION_V2};
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap},
Json,
};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
/// Guards every admin handler: pulls the bearer token out of the header and
/// compares constant-time against the configured admin key. Returns the
/// SHA-256 hex of the token on success so handlers can write an audit row
/// that identifies *which* credential made the call without logging the raw
/// key.
pub fn require_admin(state: &AppState, headers: &HeaderMap) -> AppResult<String> {
let header_val = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let token = header_val
.strip_prefix("Bearer ")
.ok_or(AppError::Unauthorized)?;
if bool::from(
token
.as_bytes()
.ct_eq(state.config.admin_api_key.as_bytes()),
) {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
Ok(hex::encode(hasher.finalize()))
} else {
Err(AppError::Forbidden)
}
}
/// Pull the best-effort client IP and User-Agent out of the request headers
/// for audit logging.
pub fn request_context(headers: &HeaderMap) -> (Option<String>, Option<String>) {
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string())
.filter(|s| !s.is_empty());
let ua = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
(client_ip, ua)
}
// ---------- Products ----------
#[derive(Debug, Deserialize)]
pub struct CreateProductReq {
pub slug: String,
pub name: String,
#[serde(default)]
pub description: String,
pub price_sats: i64,
#[serde(default)]
pub metadata: Value,
}
pub async fn create_product(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateProductReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if req.price_sats <= 0 {
return Err(AppError::BadRequest("price_sats must be positive".into()));
}
let metadata = if req.metadata.is_null() {
json!({})
} else {
req.metadata
};
let product = repo::create_product(
&state.db,
&req.slug,
&req.name,
&req.description,
req.price_sats,
&metadata,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"product.create",
Some("product"),
Some(&product.id),
ip.as_deref(),
ua.as_deref(),
&json!({ "slug": product.slug, "name": product.name, "price_sats": product.price_sats }),
)
.await;
crate::webhooks::dispatch(
&state,
"product.created",
&json!({ "product": product }),
)
.await;
Ok(Json(json!(product)))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_product_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_product_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"product.set_active",
Some("product"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
// ---------- Licenses ----------
#[derive(Debug, Deserialize)]
pub struct ListLicensesQuery {
pub product_id: String,
}
pub async fn list_licenses(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListLicensesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let licenses = repo::list_licenses_by_product(&state.db, &q.product_id).await?;
Ok(Json(json!({ "licenses": licenses })))
}
#[derive(Debug, Deserialize)]
pub struct SearchLicensesQuery {
pub buyer_email: Option<String>,
pub nostr_npub: Option<String>,
pub invoice_id: Option<String>,
}
/// Free-form lookup used by the "lost key recovery" flow. Searches by email,
/// Nostr npub, or invoice id (whichever is supplied), returns up to 100
/// matching licenses.
pub async fn search_licenses(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<SearchLicensesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let licenses = repo::search_licenses(
&state.db,
q.buyer_email.as_deref(),
q.nostr_npub.as_deref(),
q.invoice_id.as_deref(),
)
.await?;
Ok(Json(json!({ "licenses": licenses })))
}
#[derive(Debug, Deserialize)]
pub struct IssueLicenseReq {
pub product_slug: String,
/// Optional policy slug (within the product). When set, the policy's
/// duration, grace, entitlements, trial flag, and machine cap are used.
#[serde(default)]
pub policy_slug: Option<String>,
/// Optional reason for audit — e.g. "comp", "press", "giveaway".
#[serde(default)]
pub note: Option<String>,
/// Override expiry (ISO-8601 UTC). Ignored if `policy_slug` is set.
#[serde(default)]
pub expires_at: Option<String>,
/// Override entitlements. Ignored if `policy_slug` is set.
#[serde(default)]
pub entitlements: Option<Vec<String>>,
#[serde(default)]
pub max_machines: Option<i64>,
#[serde(default)]
pub grace_seconds: Option<i64>,
#[serde(default)]
pub is_trial: Option<bool>,
#[serde(default)]
pub buyer_email: Option<String>,
#[serde(default)]
pub nostr_npub: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct IssueLicenseResp {
pub license_id: String,
pub product_id: String,
pub license_key: String,
pub issued_at: String,
pub expires_at: Option<String>,
pub entitlements: Vec<String>,
pub is_trial: bool,
pub max_machines: i64,
}
/// Manually issue a license outside the purchase flow. Useful for comps,
/// press keys, grandfathered users, trial keys, or developer testing.
pub async fn issue_license(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<IssueLicenseReq>,
) -> AppResult<Json<IssueLicenseResp>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product_slug)))?;
// Pull the policy (if any) and merge it with per-call overrides.
let policy = if let Some(slug) = &req.policy_slug {
Some(
repo::get_policy_by_slug(&state.db, &product.id, slug)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{slug}' for product '{}'",
req.product_slug
))
})?,
)
} else {
None
};
// Compose effective values: explicit request fields take precedence over
// the policy, which takes precedence over defaults.
let now = Utc::now();
let issued_at = now.to_rfc3339();
let duration_seconds = policy.as_ref().map(|p| p.duration_seconds).unwrap_or(0);
let expires_at = match (req.expires_at.clone(), duration_seconds) {
(Some(explicit), _) => Some(explicit),
(None, 0) => None, // perpetual
(None, secs) => Some((now + Duration::seconds(secs)).to_rfc3339()),
};
let grace_seconds = req
.grace_seconds
.or_else(|| policy.as_ref().map(|p| p.grace_seconds))
.unwrap_or(0);
let max_machines = req
.max_machines
.or_else(|| policy.as_ref().map(|p| p.max_machines))
.unwrap_or(1);
let is_trial = req
.is_trial
.or_else(|| policy.as_ref().map(|p| p.is_trial))
.unwrap_or(false);
let entitlements = req
.entitlements
.clone()
.or_else(|| policy.as_ref().map(|p| p.entitlements.clone()))
.unwrap_or_default();
let license_id = uuid::Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&license_id,
&product.id,
None,
&issued_at,
&json!({
"source": "admin_issue",
"note": req.note,
}),
policy.as_ref().map(|p| p.id.as_str()),
expires_at.as_deref(),
grace_seconds,
max_machines,
&entitlements,
is_trial,
req.buyer_email.as_deref(),
req.nostr_npub.as_deref(),
)
.await?;
// Build v2 signed payload.
let mut flags = 0u8;
if is_trial {
flags |= crate::crypto::FLAG_TRIAL;
}
let payload = LicensePayload {
version: KEY_VERSION_V2,
flags,
product_id: uuid::Uuid::parse_str(&product.id).unwrap(),
license_id: uuid::Uuid::parse_str(&license_id).unwrap(),
issued_at: now.timestamp(),
expires_at: expires_at
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp())
.unwrap_or(0),
fingerprint_hash: [0u8; 32],
entitlements: entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
let license_key = encode_key(&payload, &sig);
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.issue_manual",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({
"product_id": product.id,
"policy_id": policy.as_ref().map(|p| &p.id),
"is_trial": is_trial,
"expires_at": expires_at,
"entitlements": entitlements,
}),
)
.await;
crate::webhooks::dispatch(
&state,
"license.issued",
&json!({
"license_id": license_id,
"product_id": product.id,
"is_trial": is_trial,
"expires_at": expires_at,
"entitlements": entitlements,
"source": "admin_issue",
}),
)
.await;
Ok(Json(IssueLicenseResp {
license_id,
product_id: product.id,
license_key,
issued_at,
expires_at,
entitlements,
is_trial,
max_machines,
}))
}
#[derive(Debug, Deserialize)]
pub struct RevokeReq {
#[serde(default)]
pub reason: String,
}
pub async fn revoke_license(
State(state): State<AppState>,
headers: HeaderMap,
Path(license_id): Path<String>,
Json(req): Json<RevokeReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin revoke".to_string()
} else {
req.reason
};
repo::revoke_license(&state.db, &license_id, &reason).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.revoke",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "reason": reason }),
)
.await;
crate::webhooks::dispatch(
&state,
"license.revoked",
&json!({ "license_id": license_id, "reason": reason }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
// ---------- Suspension / un-suspension ----------
#[derive(Debug, Deserialize)]
pub struct SuspendReq {
#[serde(default)]
pub reason: String,
}
pub async fn suspend_license(
State(state): State<AppState>,
headers: HeaderMap,
Path(license_id): Path<String>,
Json(req): Json<SuspendReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin suspend".to_string()
} else {
req.reason
};
repo::suspend_license(&state.db, &license_id, &reason).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.suspend",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "reason": reason }),
)
.await;
crate::webhooks::dispatch(
&state,
"license.suspended",
&json!({ "license_id": license_id, "reason": reason }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
pub async fn unsuspend_license(
State(state): State<AppState>,
headers: HeaderMap,
Path(license_id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::unsuspend_license(&state.db, &license_id).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.unsuspend",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
crate::webhooks::dispatch(
&state,
"license.unsuspended",
&json!({ "license_id": license_id }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
// ---------- Audit log viewer ----------
#[derive(Debug, Deserialize)]
pub struct ListAuditQuery {
#[serde(default = "default_audit_limit")]
pub limit: i64,
pub action: Option<String>,
}
fn default_audit_limit() -> i64 {
200
}
pub async fn list_audit(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListAuditQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let rows = repo::list_audit(&state.db, q.limit.min(1000).max(1), q.action.as_deref()).await?;
Ok(Json(json!({ "entries": rows })))
}
// ---------- Settings (live-mutable runtime config) ----------
/// Settings key for the operator's public-facing display name. Read by
/// the `/` index handler on every request, so updates take effect
/// immediately — no daemon restart needed.
pub const SETTING_OPERATOR_NAME: &str = "operator_name";
#[derive(Debug, Deserialize)]
pub struct SetOperatorNameReq {
/// New operator name. Empty string clears the setting (reverts to
/// the daemon's startup-time fallback from KEYSAT_OPERATOR_NAME).
pub name: String,
}
pub async fn set_operator_name(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<SetOperatorNameReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let trimmed = req.name.trim();
let stored: Option<&str> = if trimmed.is_empty() { None } else { Some(trimmed) };
repo::settings_set(&state.db, SETTING_OPERATOR_NAME, stored).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"operator_name.set",
Some("setting"),
Some(SETTING_OPERATOR_NAME),
ip.as_deref(),
ua.as_deref(),
&json!({ "value": stored }),
)
.await;
Ok(Json(json!({ "ok": true, "operator_name": stored })))
}
pub async fn get_operator_name(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let stored = repo::settings_get(&state.db, SETTING_OPERATOR_NAME).await?;
let effective = stored
.clone()
.or_else(|| state.config.operator_name.clone());
Ok(Json(json!({
"stored": stored,
"effective": effective,
"fallback_env": state.config.operator_name,
})))
}
+86
View File
@@ -0,0 +1,86 @@
//! Embedded admin web UI.
//!
//! At compile time, every file in `licensing-service/web/` is bundled
//! into the binary via `rust-embed`. At runtime, axum serves them under
//! `/admin/*` — no separate static-file deployment, no nginx, no proxy.
//! The whole admin SPA ships in the same `keysat` executable as the
//! daemon.
//!
//! Auth model: NONE at this HTTP layer. The static assets themselves
//! (HTML, CSS, JS) are public — there's nothing secret in them. The
//! actual gating happens client-side: the index page prompts for the
//! operator's admin API key on first load, validates it against any
//! `/v1/admin/*` endpoint, stores it in localStorage, and uses it as
//! `Authorization: Bearer ...` on every subsequent admin call. The
//! admin-scoped endpoints already enforce the key constant-time, so a
//! random visitor can load `/admin/index.html` but cannot do anything
//! useful without the key.
//!
//! v0.2 first cut: this is scaffolding only. The HTML page contains a
//! login form + a placeholder dashboard. Future SPA work just adds
//! more files into `web/` (or replaces index.html with a built React /
//! Svelte bundle); the serving code below doesn't change.
use axum::{
body::Body,
http::{header, StatusCode, Uri},
response::{IntoResponse, Redirect, Response},
};
use rust_embed::RustEmbed;
/// Compile-time-bundled directory of static admin UI assets. Every file
/// under `web/` (relative to the crate root) is embedded byte-for-byte
/// into the binary.
#[derive(RustEmbed)]
#[folder = "web/"]
struct AdminAssets;
/// `GET /admin` — redirect to `/admin/` so the relative paths in the
/// embedded HTML resolve correctly.
pub async fn admin_root_redirect() -> Redirect {
Redirect::permanent("/admin/")
}
/// `GET /admin/` — serve the SPA shell (index.html).
pub async fn admin_index() -> Response {
serve_embedded("index.html")
}
/// `GET /admin/*path` — serve any other embedded static file. Falls
/// through to `index.html` for unknown paths so client-side routing
/// (e.g. /admin/products, /admin/licenses) works without server-side
/// route registration.
pub async fn admin_asset(uri: Uri) -> Response {
// The Uri here will be the FULL path (including the /admin prefix).
// Strip the prefix to look up the asset.
let path = uri.path();
let stripped = path.strip_prefix("/admin/").unwrap_or(path);
if stripped.is_empty() {
return serve_embedded("index.html");
}
if AdminAssets::get(stripped).is_some() {
serve_embedded(stripped)
} else {
// Unknown path — fall through to index.html so the SPA's
// client-side router can take over. This is the canonical
// fallback pattern for SPAs hosted on path prefixes.
serve_embedded("index.html")
}
}
fn serve_embedded(path: &str) -> Response {
match AdminAssets::get(path) {
Some(file) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.header(header::CONTENT_TYPE, mime.to_string())
// Modest caching — these are versioned with the binary,
// so cache for an hour. A binary upgrade rolls the
// service which evicts the cache anyway.
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(Body::from(file.data.into_owned()))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
None => StatusCode::NOT_FOUND.into_response(),
}
}
@@ -0,0 +1,415 @@
//! BTCPay one-click authorize flow.
//!
//! Instead of making the operator generate an API key by hand and paste it
//! into a form, we use BTCPay's "authorize" redirect flow:
//!
//! 1. Operator clicks "Connect BTCPay" in StartOS — the wrapper action
//! calls `POST /v1/admin/btcpay/connect` (with the admin bearer token)
//! and gets back a BTCPay URL to open in the operator's browser.
//! 2. The operator, already logged into BTCPay on the same box, sees a
//! consent page listing the permissions this service is requesting. They
//! click **Authorize**.
//! 3. BTCPay POSTs back to our `/v1/btcpay/authorize/callback` with the
//! newly-minted API key and the store(s) it was scoped to.
//! 4. We persist the key, pick the target store, register the webhook (with
//! a freshly-generated secret), and save everything in `btcpay_config`.
//! 5. From that moment on, the `BtcpayProvider` (held as an `Arc<dyn
//! PaymentProvider>` in `AppState.payment`) is populated
//! and purchase / webhook endpoints work.
//!
//! If the callback fails for any reason, the operator is shown an error page
//! and can retry. The admin endpoint requires the admin bearer token; the
//! callback path uses the CSRF `state` token to tie a callback back to the
//! issuing operator session.
use crate::api::{admin::require_admin, AppState};
use crate::btcpay::client::{self as btcpay_client, BtcpayClient};
use crate::btcpay::config as btcpay_cfg;
use crate::error::{AppError, AppResult};
use crate::payment::btcpay::BtcpayProvider;
use std::sync::Arc;
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
Form, Json,
};
use data_encoding::BASE32_NOPAD;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// Permissions we request on the authorize page. Each is namespaced by
/// `btcpay.store.*` which means BTCPay will prompt the operator to pick
/// which store(s) to grant.
const REQUESTED_PERMISSIONS: &[&str] = &[
"btcpay.store.canviewstoresettings",
"btcpay.store.canmodifystoresettings", // to register the webhook
"btcpay.store.canviewinvoices",
"btcpay.store.cancreateinvoice",
"btcpay.store.canmodifyinvoices",
];
#[derive(Debug, Serialize)]
pub struct ConnectResp {
/// URL the operator should open in their browser to authorize.
pub authorize_url: String,
/// CSRF state token tied to this round trip.
pub state: String,
}
/// Admin endpoint: starts a connect round trip. Returns the BTCPay authorize
/// URL for the StartOS wrapper action to open in the operator's browser.
pub async fn start_connect(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<ConnectResp>> {
require_admin(&state, &headers)?;
// Idempotency: if BTCPay is already connected, refuse to issue a new
// authorize URL. Re-clicking Connect today produces a duplicate
// webhook subscription on BTCPay, which results in every payment
// event being delivered to Keysat twice. Make the operator go
// through Disconnect first if they really want to re-authorize.
if let Ok(Some(existing)) = btcpay_cfg::load(&state.db).await {
return Err(AppError::Conflict(format!(
"BTCPay is already connected (store {}). Run 'Disconnect BTCPay' first if you need to re-authorize.",
existing.store_id,
)));
}
// Random 20-byte token, base32-encoded, for the CSRF `state` parameter.
let mut raw = [0u8; 20];
rand::thread_rng().fill_bytes(&mut raw);
let state_token = BASE32_NOPAD.encode(&raw);
btcpay_cfg::record_authorize_state(&state.db, &state_token)
.await
.map_err(AppError::Internal)?;
// Construct the authorize URL per BTCPay's docs.
// https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys
//
// CSRF state must travel inside the `redirect` URL itself, NOT as a
// separate query param on the outer authorize URL. Empirical
// observation against BTCPay: arbitrary query params on the
// authorize URL are NOT forwarded to the redirect target. The
// redirect URL is preserved verbatim, so any params we encode INTO
// it survive the round-trip.
let redirect = format!(
"{}/v1/btcpay/authorize/callback?state={}",
state.config.public_base_url,
urlencoding::encode(&state_token),
);
let perm_params = REQUESTED_PERMISSIONS
.iter()
.map(|p| format!("permissions={}", urlencoding::encode(p)))
.collect::<Vec<_>>()
.join("&");
// The authorize URL is followed by the operator's BROWSER, so the host
// must be reachable from outside the container. Use the explicit
// `btcpay_browser_url` if the wrapper provided it; fall back to
// `btcpay_url` only for dev/local setups (where they're the same).
let authorize_base = state
.config
.btcpay_browser_url
.as_deref()
.unwrap_or(&state.config.btcpay_url);
let authorize_url = format!(
"{}/api-keys/authorize?applicationName={}&applicationIdentifier={}&strict=true&selectiveStores=true&redirect={}&{perm_params}",
authorize_base,
urlencoding::encode("Keysat"),
urlencoding::encode("keysat"),
urlencoding::encode(&redirect),
);
Ok(Json(ConnectResp {
authorize_url,
state: state_token,
}))
}
/// Fields BTCPay sends back on the callback. BTCPay POSTs `apiKey`,
/// `userId`, and `permissions[]` as a form body. It also preserves any
/// query-string parameters on the redirect URL — we use that for `state`.
#[derive(Debug, Deserialize)]
pub struct CallbackForm {
#[serde(rename = "apiKey")]
pub api_key: String,
#[serde(rename = "userId")]
pub user_id: Option<String>,
// BTCPay posts `permissions` one-per-occurrence; serde_urlencoded turns
// that into a repeated string. We don't actually need to parse them
// individually — we just re-verify via list_stores.
#[serde(default)]
pub permissions: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct CallbackQuery {
pub state: String,
}
/// The real callback endpoint — POST form-encoded.
pub async fn callback(
State(state): State<AppState>,
Query(q): Query<CallbackQuery>,
Form(form): Form<CallbackForm>,
) -> AppResult<Response> {
finish_connect(&state, &q.state, &form.api_key).await?;
Ok(success_page("BTCPay connected successfully. You can close this tab and return to StartOS."))
}
/// Some BTCPay deployments send the apiKey back as a query string on a GET.
/// Handle that too for robustness.
#[derive(Debug, Deserialize)]
pub struct CallbackGetQuery {
pub state: String,
#[serde(rename = "apiKey")]
pub api_key: Option<String>,
/// Error message if BTCPay declined / operator clicked "Deny".
pub error: Option<String>,
}
pub async fn callback_get(
State(state): State<AppState>,
Query(q): Query<CallbackGetQuery>,
) -> Response {
if let Some(err) = q.error {
return Html(format!(
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
html_escape::encode_text(&err)
))
.into_response();
}
let Some(api_key) = q.api_key else {
// Some installs POST; in that case a bare GET with no apiKey is
// possible if the operator refreshes the tab. Redirect to root.
return Redirect::to("/").into_response();
};
match finish_connect(&state, &q.state, &api_key).await {
Ok(()) => success_page(
"BTCPay connected successfully. You can close this tab and return to StartOS.",
),
Err(e) => Html(format!(
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
html_escape::encode_text(&e.to_string())
))
.into_response(),
}
}
/// Admin endpoint: list payment methods configured on the connected
/// BTCPay store. Proxies to BTCPay's `/api/v1/stores/{id}/payment-methods`.
/// Used by the wrapper / future web UI to surface a "no wallet
/// configured" state.
pub async fn payment_methods(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = btcpay_cfg::load(&state.db)
.await
.map_err(AppError::Internal)?
.ok_or(AppError::BtcpayNotConfigured)?;
let methods = btcpay_client::list_payment_methods(&cfg.base_url, &cfg.api_key, &cfg.store_id)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e}")))?;
// Return both the raw array for callers that want detail, and a
// boolean summary for the common "is anything configured?" check.
let count = methods.len();
Ok(Json(json!({
"store_id": cfg.store_id,
"count": count,
"methods": methods,
})))
}
/// Admin endpoint: report current BTCPay connection status.
pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = btcpay_cfg::load(&state.db).await.map_err(AppError::Internal)?;
Ok(Json(match cfg {
None => json!({ "connected": false }),
Some(c) => json!({
"connected": true,
"store_id": c.store_id,
"webhook_id": c.webhook_id,
"base_url": c.base_url,
}),
}))
}
// --- internals ---
async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> {
btcpay_cfg::consume_authorize_state(&state.db, state_token)
.await
.map_err(|_| AppError::Unauthorized)?;
let base_url = &state.config.btcpay_url;
// Enumerate stores the key has access to. With `selectiveStores=true`
// the operator picked specific stores during authorize; we pick the
// first one that the key can see.
let stores = btcpay_client::list_stores(base_url, api_key)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e}")))?;
let store = stores
.into_iter()
.next()
.ok_or_else(|| AppError::BadRequest(
"The authorized API key has access to zero stores. Re-run connect and pick a store.".into()
))?;
// Generate a strong webhook secret, then register the webhook on BTCPay.
let mut raw_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw_secret);
let webhook_secret = BASE32_NOPAD.encode(&raw_secret);
let callback_url = format!("{}/v1/btcpay/webhook", state.config.public_base_url);
let created_webhook = btcpay_client::create_webhook(
base_url,
api_key,
&store.id,
&callback_url,
&webhook_secret,
)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e}")))?;
// Persist.
let cfg = btcpay_cfg::BtcpayConfig {
base_url: base_url.clone(),
api_key: api_key.to_string(),
store_id: store.id.clone(),
webhook_id: Some(created_webhook.id.clone()),
webhook_secret: webhook_secret.clone(),
};
btcpay_cfg::save(&state.db, &cfg)
.await
.map_err(AppError::Internal)?;
// Swap runtime — wrap a fresh BtcpayProvider into the
// PaymentProvider trait object held by AppState. Pass the
// public-facing BTCPay URL too so that checkout URLs returned to
// buyers get rewritten from the internal Docker hostname to a
// browser-reachable host.
let client = BtcpayClient::new(base_url, api_key, &store.id);
let provider = Arc::new(
BtcpayProvider::new(client, webhook_secret)
.with_public_base(state.config.btcpay_public_url.clone()),
);
state.set_payment_provider(provider).await;
tracing::info!(
store = %store.id,
store_name = %store.name,
webhook_id = %created_webhook.id,
"BTCPay connected via authorize flow"
);
Ok(())
}
fn success_page(msg: &str) -> Response {
let body = format!(
r#"<!doctype html><html><head><meta charset="utf-8"><title>BTCPay connected</title>
<style>body{{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:1rem;line-height:1.5}}
h2{{color:#0a7}}</style></head>
<body><h2>✓ {msg}</h2></body></html>"#,
msg = html_escape::encode_text(msg)
);
(StatusCode::OK, Html(body)).into_response()
}
/// Admin endpoint: disconnect BTCPay. Best-effort revocation of the
/// webhook + API key on BTCPay's side, then unconditional clear of the
/// local config row. If BTCPay is unreachable, the local state is still
/// cleared and the operator gets a warning to clean up BTCPay manually.
pub async fn disconnect(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = crate::api::admin::request_context(&headers);
let cfg = btcpay_cfg::load(&state.db)
.await
.map_err(AppError::Internal)?;
let Some(cfg) = cfg else {
return Ok(Json(json!({
"ok": true,
"noop": true,
"message": "BTCPay was not connected; nothing to do.",
})));
};
// Capture metadata for the response BEFORE we clear local state.
let store_id = cfg.store_id.clone();
let webhook_id = cfg.webhook_id.clone();
// Best-effort remote cleanup. We DON'T short-circuit if either of
// these calls fails — the operator's intent is to disconnect, and
// leaving local state pointing at a remote we no longer trust is
// worse than leaving orphan state on the BTCPay side. Any failures
// are surfaced in the response so the operator can manually clean
// up on BTCPay if needed.
let mut warnings: Vec<String> = Vec::new();
if let Some(webhook_id) = webhook_id.as_deref() {
if let Err(e) = btcpay_client::delete_webhook(
&cfg.base_url,
&cfg.api_key,
&cfg.store_id,
webhook_id,
)
.await
{
warnings.push(format!(
"Could not delete BTCPay webhook {webhook_id}: {e}. \
You may want to manually delete it in BTCPay's store webhook settings."
));
}
}
if let Err(e) = btcpay_client::revoke_api_key(&cfg.base_url, &cfg.api_key).await {
warnings.push(format!(
"Could not revoke BTCPay API key: {e}. \
You may want to manually revoke it in BTCPay's account API-keys page."
));
}
btcpay_cfg::clear(&state.db)
.await
.map_err(AppError::Internal)?;
// Replace the runtime payment provider so subsequent purchase
// attempts return BtcpayNotConfigured cleanly.
state.clear_payment_provider().await;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"btcpay.disconnect",
Some("btcpay_config"),
None,
ip.as_deref(),
ua.as_deref(),
&json!({ "store_id": store_id, "webhook_id": webhook_id }),
)
.await;
Ok(Json(json!({
"ok": true,
"noop": false,
"store_id": store_id,
"webhook_id": webhook_id,
"warnings": warnings,
})))
}
+640
View File
@@ -0,0 +1,640 @@
//! Public buyer-facing purchase page at `GET /buy/:slug`.
//!
//! The flow is:
//! 1. Buyer hits `https://<operator-keysat>/buy/<product-slug>` in a browser.
//! 2. We look up the product, render an HTML page showing what they're
//! buying — name, description, price — plus a small form for an
//! optional email (for receipt + license delivery) and an optional
//! discount code.
//! 3. They click "Pay with Bitcoin." Inline JS POSTs to `/v1/purchase`,
//! gets back a BTCPay checkout URL, redirects the browser there.
//! 4. After payment, BTCPay redirects to `/thank-you` (existing handler).
//!
//! Visual language matches the rest of the Keysat design system: navy
//! topbar, cream paper-textured background, gold accent on the price and
//! the CTA, classical type. Inlined CSS so this single file is the whole
//! buyer-facing surface — easy to deploy, no asset hosting required.
use crate::api::AppState;
use crate::db::repo;
use axum::{
extract::{Path, State},
http::StatusCode,
response::Html,
};
pub async fn render(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Html<String>, (StatusCode, Html<String>)> {
// Look up the product. Inactive or missing → 404 with a friendly page.
let product = match repo::get_product_by_slug(&state.db, &slug).await {
Ok(Some(p)) if p.active => p,
_ => return Err((StatusCode::NOT_FOUND, Html(not_found_html(&slug)))),
};
// Live-read operator name (same pattern as thank-you / root).
let live = 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 product_name = html_escape(&product.name);
let product_slug = html_escape(&product.slug);
let product_description = html_escape(&product.description);
let price_sats_fmt = format_thousands(product.price_sats);
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>Buy {product_name} — {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:42px;
line-height:1.05; letter-spacing:-0.022em; color:var(--navy-950);
margin:0 0 12px;
}}
.product-slug {{
font-family:var(--font-mono); font-size:12.5px; color:var(--ink-500);
margin:0 0 18px;
}}
.description {{
font-size:16px; line-height:1.55; color:var(--ink-700);
margin:0 0 32px;
}}
.cert {{
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:14px;
box-shadow:0 0 0 1px var(--gold-500) inset, var(--shadow-md);
padding:32px 32px 28px;
position:relative;
margin-bottom:24px;
}}
.cert::before, .cert::after {{
content:''; position:absolute; left:14px; right:14px;
height:1px; background:var(--gold-500); opacity:0.5;
}}
.cert::before {{ top:14px; }} .cert::after {{ bottom:14px; }}
.price {{
font-family:var(--font-display); font-weight:700; font-size:36px;
color:var(--navy-950); letter-spacing:-0.025em; margin:8px 0 0;
}}
.price .unit {{
font-family:var(--font-body); font-size:15px; font-weight:600;
color:var(--ink-500); margin-left:8px;
}}
.price-label {{
font-size:11.5px; font-weight:700; letter-spacing:0.14em;
text-transform:uppercase; color:var(--ink-500);
}}
.field {{ margin-bottom:14px; }}
.field label {{
display:block; font-size:12.5px; font-weight:600;
color:var(--ink-700); margin-bottom:6px;
}}
.field input {{
width:100%; padding:11px 13px;
font-family:var(--font-body); font-size:14px;
border:1px solid var(--border-2); border-radius:8px;
background:#fff; color:var(--ink-900);
}}
.field input:focus {{
outline:none; border-color:var(--navy-700);
box-shadow:0 0 0 3px rgba(30,58,95,0.18);
}}
.field .hint {{ font-size:12px; color:var(--ink-500); margin-top:5px; }}
/* Apply-discount cluster: input + button on one row */
.code-row {{ display:flex; gap:8px; align-items:stretch; }}
.code-row input {{ flex:1; }}
.btn-apply {{
background:transparent; color:var(--navy-800);
border:1px solid var(--border-2); border-radius:8px;
padding:0 16px;
font-family:var(--font-body); font-weight:600; font-size:13px;
cursor:pointer; transition:all 120ms;
flex-shrink:0;
}}
.btn-apply:hover {{ background:var(--cream-200); border-color:var(--navy-700); }}
.btn-apply:disabled {{ opacity:0.5; cursor:wait; }}
.code-status {{
margin-top:8px; font-size:13px; padding:8px 12px;
border-radius:7px; display:none;
}}
.code-status.show {{ display:block; }}
.code-status.ok {{ background:var(--success-bg); color:#205c47; border:1px solid rgba(45,122,95,0.25); }}
.code-status.bad {{ background:var(--danger-bg); color:#8a2828; border:1px solid rgba(178,58,58,0.25); }}
/* Price card update animation when discount applied */
.price-strike {{
text-decoration:line-through; color:var(--ink-500);
font-size:18px; font-weight:500; display:block;
margin-bottom:4px;
}}
.price-discount-tag {{
display:inline-block; margin-left:8px;
font-family:var(--font-body); font-size:12px; font-weight:600;
padding:3px 10px; border-radius:999px;
background:var(--success-bg); color:#205c47;
border:1px solid rgba(45,122,95,0.25);
vertical-align:middle;
}}
.btn-pay {{
width:100%; padding:14px;
background:var(--navy-800); color:var(--cream-50);
border:0; border-radius:10px;
font-family:var(--font-body); font-weight:600; font-size:15px;
cursor:pointer; transition:background 120ms;
margin-top:16px;
display:inline-flex; align-items:center; justify-content:center; gap:8px;
}}
.btn-pay:hover {{ background:var(--navy-900); }}
.btn-pay:disabled {{ opacity:0.6; cursor:wait; }}
.btn-pay svg {{ width:18px; height:18px; }}
.error {{
margin-top:14px; padding:10px 14px;
background:var(--danger-bg); color:#8a2828;
border:1px solid rgba(178,58,58,0.25);
border-radius:7px; font-size:13.5px;
display:none;
}}
.error.show {{ display:block; }}
.license-success {{
display:none; margin-top:24px;
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:14px;
box-shadow:0 0 0 1px var(--gold-500) inset, 0 8px 16px rgba(14,31,51,0.10);
padding:32px 32px 28px; position:relative;
}}
.license-success.show {{ display:block; }}
.license-success::before, .license-success::after {{
content:''; position:absolute; left:14px; right:14px;
height:1px; background:var(--gold-500); opacity:0.5;
}}
.license-success::before {{ top:14px; }}
.license-success::after {{ bottom:14px; }}
.license-success .stamp {{
font-size:10px; font-weight:700; letter-spacing:0.22em;
text-transform:uppercase; color:var(--gold-700);
text-align:center; margin-bottom:16px;
}}
.license-success h3 {{
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;
}}
.license-success .subtitle {{
font-size:14px; color:var(--ink-500); text-align:center;
margin:0 0 22px;
}}
.license-success .field-label {{
font-size:11px; font-weight:600; letter-spacing:0.12em;
text-transform:uppercase; color:var(--ink-500); margin-bottom:6px;
}}
.license-success .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;
}}
.license-success .key-box .key-text {{ flex:1; }}
.license-success .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;
}}
.license-success .key-box button:hover {{ background:rgba(245,241,232,0.20); }}
.license-success .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;
}}
.license-success .save-note strong {{ color:var(--navy-950); }}
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); }}
</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">Buy a license</div>
<h1>{product_name}</h1>
<div class="product-slug">{product_slug}</div>
<p class="description">{product_description}</p>
<div class="cert">
<div class="price-label">Price</div>
<div class="price" id="price-display">
<span id="price-strike-line" class="price-strike" style="display:none"></span>
<span id="price-current">{price_sats_fmt}</span><span class="unit">sats</span>
<span id="price-discount-tag" class="price-discount-tag" style="display:none"></span>
</div>
</div>
<form id="buy-form">
<div class="field">
<label for="email">Email (for receipt &amp; license)</label>
<input type="email" id="email" name="email" placeholder="you@example.com" required>
<div class="hint">We&rsquo;ll send your license key here after payment confirms.</div>
</div>
<div class="field">
<label for="code">Discount code (optional)</label>
<div class="code-row">
<input type="text" id="code" name="code" placeholder="FOUNDERS50" autocomplete="off">
<button type="button" class="btn-apply" id="btn-apply">Apply</button>
</div>
<div class="code-status" id="code-status" role="status" aria-live="polite"></div>
</div>
<button type="submit" class="btn-pay" id="btn-pay">
<svg id="btn-pay-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.5 8.5h5a2 2 0 010 4h-5m0 0h5a2 2 0 010 4h-5m0-8v8m2-10v2m0 8v2"></path>
</svg>
<span id="btn-pay-label">Pay with Bitcoin</span>
</button>
<div class="error" id="err"></div>
</form>
<div class="license-success" id="license-success" role="region" aria-label="License issued">
<div class="stamp">&mdash; License issued &mdash;</div>
<h3>You&rsquo;re licensed.</h3>
<p class="subtitle">No payment needed for this code. Your signed license is below.</p>
<div class="field-label">License key</div>
<div class="key-box">
<span class="key-text" id="license-key-text">&hellip;</span>
<button id="license-key-copy">Copy</button>
</div>
<div class="save-note">
<strong>Save this somewhere safe.</strong> The license key is signed at issue time and verifies offline. We&rsquo;ll also send a copy to <span id="license-email-display"></span> for your records.
</div>
</div>
</div>
<footer class="kfooter">
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-paid software licensing</span>
</footer>
<script>
(function() {{
const form = document.getElementById('buy-form');
const btn = document.getElementById('btn-pay');
const btnLabel = document.getElementById('btn-pay-label');
const btnIcon = document.getElementById('btn-pay-icon');
const errEl = document.getElementById('err');
const successEl = document.getElementById('license-success');
const keyTextEl = document.getElementById('license-key-text');
const emailDisplayEl = document.getElementById('license-email-display');
const codeInput = document.getElementById('code');
const applyBtn = document.getElementById('btn-apply');
const codeStatus = document.getElementById('code-status');
const priceCurrent = document.getElementById('price-current');
const priceStrike = document.getElementById('price-strike-line');
const priceTag = document.getElementById('price-discount-tag');
const PRODUCT_SLUG = {slug_json};
const BASE_PRICE_FMT = priceCurrent.textContent;
// State of the most recent successful Apply. When set with kind=free_license
// and the same code is still in the input, the submit handler skips the
// "try /v1/redeem then fall through" dance and goes straight to redeem.
let appliedCode = null; // {{ code, kind, is_free, final_price_sats }}
function showError(msg) {{
errEl.textContent = msg;
errEl.classList.add('show');
}}
function clearError() {{ errEl.classList.remove('show'); }}
function showLicense(licenseKey, email) {{
keyTextEl.textContent = licenseKey;
emailDisplayEl.textContent = email || '(no email provided)';
form.style.display = 'none';
successEl.classList.add('show');
successEl.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
}}
function fmtNum(n) {{
return Number(n).toLocaleString('en-US');
}}
function setStatus(kind, text) {{
codeStatus.classList.remove('ok', 'bad');
if (!kind) {{ codeStatus.classList.remove('show'); codeStatus.textContent = ''; return; }}
codeStatus.classList.add(kind === 'ok' ? 'ok' : 'bad', 'show');
codeStatus.textContent = text;
}}
function resetPrice() {{
priceCurrent.textContent = BASE_PRICE_FMT;
priceStrike.style.display = 'none';
priceStrike.textContent = '';
priceTag.style.display = 'none';
priceTag.textContent = '';
}}
function setPaidButton() {{
btnLabel.textContent = 'Pay with Bitcoin';
btnIcon.style.display = '';
}}
function setRedeemButton() {{
btnLabel.textContent = 'Redeem license';
btnIcon.style.display = 'none';
}}
// Reset apply state if the buyer edits the code after a successful Apply.
codeInput.addEventListener('input', function() {{
if (appliedCode && codeInput.value.trim().toUpperCase() !== appliedCode.code.toUpperCase()) {{
appliedCode = null;
resetPrice();
setPaidButton();
setStatus(null);
}}
}});
applyBtn.addEventListener('click', async function() {{
clearError();
const code = codeInput.value.trim();
if (!code) {{
setStatus('bad', 'Enter a code first.');
return;
}}
applyBtn.disabled = true;
const orig = applyBtn.textContent;
applyBtn.textContent = 'Checking…';
try {{
const url = '/v1/discount-codes/preview?code='
+ encodeURIComponent(code) + '&product=' + encodeURIComponent(PRODUCT_SLUG);
const resp = await fetch(url);
if (!resp.ok) {{
let msg = 'HTTP ' + resp.status;
try {{ const j = await resp.json(); msg = j.message || j.error || msg; }} catch(_) {{}}
throw new Error(msg);
}}
const j = await resp.json();
if (!j.valid) {{
appliedCode = null;
resetPrice();
setPaidButton();
setStatus('bad', j.message || 'Code not valid.');
return;
}}
appliedCode = {{
code: j.code,
kind: j.kind,
is_free: !!j.is_free,
final_price_sats: j.final_price_sats,
}};
// Update price card
if (j.kind === 'free_license' || j.final_price_sats === 0) {{
priceStrike.textContent = fmtNum(j.base_price_sats) + ' sats';
priceStrike.style.display = 'block';
priceCurrent.textContent = 'FREE';
priceTag.textContent = '100% off';
priceTag.style.display = 'inline-block';
setRedeemButton();
}} else {{
priceStrike.textContent = fmtNum(j.base_price_sats) + ' sats';
priceStrike.style.display = 'block';
priceCurrent.textContent = fmtNum(j.final_price_sats);
if (j.kind === 'percent') {{
priceTag.textContent = (j.amount_pct || ((j.discount_applied_sats / j.base_price_sats) * 100).toFixed(0)) + '% off';
}} else {{
priceTag.textContent = fmtNum(j.discount_applied_sats) + ' sats off';
}}
priceTag.style.display = 'inline-block';
setPaidButton();
}}
setStatus('ok', j.message || 'Code applied.');
}} catch (err) {{
appliedCode = null;
resetPrice();
setPaidButton();
setStatus('bad', err.message || 'Could not validate code.');
}} finally {{
applyBtn.disabled = false;
applyBtn.textContent = orig;
}}
}});
// Try free-license redemption first if a code was provided. If that
// path returns "this code requires payment", fall through to the
// BTCPay flow with the code applied. Any other error stops here.
async function tryFreeRedeem(code, email) {{
const resp = await fetch('/v1/redeem', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{
product: PRODUCT_SLUG,
code,
buyer_email: email || undefined,
}}),
}});
if (resp.ok) {{
const j = await resp.json();
return {{ ok: true, license_key: j.license_key }};
}}
let msg = 'HTTP ' + resp.status;
try {{
const j = await resp.json();
msg = j.message || j.error || msg;
}} catch (_) {{}}
// Distinguish "fall through to paid flow" from real errors.
if (resp.status === 400 && /requires payment/i.test(msg)) {{
return {{ ok: false, fallThrough: true }};
}}
return {{ ok: false, fallThrough: false, msg }};
}}
async function startPaidPurchase(code, email) {{
const body = {{ product: PRODUCT_SLUG, buyer_email: email || undefined }};
if (code) body.code = code;
const resp = await fetch('/v1/purchase', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(body),
}});
if (!resp.ok) {{
let msg = 'HTTP ' + resp.status;
try {{
const j = await resp.json();
msg = j.message || j.error || msg;
}} catch (_) {{}}
throw new Error(msg);
}}
const j = await resp.json();
if (!j.checkout_url) throw new Error('No checkout URL returned by server');
window.location.href = j.checkout_url;
}}
// "Copy" on the license key box.
document.getElementById('license-key-copy').addEventListener('click', async function() {{
try {{
await navigator.clipboard.writeText(keyTextEl.textContent);
this.textContent = 'Copied';
setTimeout(() => {{ this.textContent = 'Copy'; }}, 1400);
}} catch (e) {{}}
}});
form.addEventListener('submit', async function(e) {{
e.preventDefault();
clearError();
btn.disabled = true;
const originalLabel = btnLabel.textContent;
btnLabel.textContent = 'Working…';
const email = document.getElementById('email').value.trim();
const code = codeInput.value.trim();
const codeMatchesApplied = appliedCode &&
code.toUpperCase() === appliedCode.code.toUpperCase();
try {{
// Fast path: a free_license code was already validated via Apply.
if (codeMatchesApplied && appliedCode.is_free) {{
const r = await tryFreeRedeem(code, email);
if (r.ok) {{ showLicense(r.license_key, email); return; }}
// If the server changed its mind, surface the error rather than silently
// routing to a paid flow that the buyer didn't consent to.
throw new Error(r.msg || 'Could not redeem free license.');
}}
// Slower path (no Apply or non-free code): keep the original try-then-fallthrough.
if (code) {{
const r = await tryFreeRedeem(code, email);
if (r.ok) {{ showLicense(r.license_key, email); return; }}
if (!r.fallThrough) {{
throw new Error(r.msg || 'Code rejected');
}}
// else fall through to the BTCPay path with the code applied
}}
btnLabel.textContent = 'Creating invoice…';
await startPaidPurchase(code, email);
}} catch (err) {{
showError('Could not complete: ' + (err.message || err));
btn.disabled = false;
btnLabel.textContent = originalLabel;
}}
}});
}})();
</script>
</body>
</html>
"#,
operator = operator,
product_name = product_name,
product_slug = product_slug,
product_description = product_description,
price_sats_fmt = price_sats_fmt,
slug_json = serde_json::to_string(&product.slug).unwrap_or_else(|_| "\"\"".into()),
);
Ok(Html(body))
}
fn not_found_html(slug: &str) -> String {
let slug_safe = html_escape(slug);
format!(
r#"<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Product not found</title>
<style>
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
max-width:32rem;margin:4rem auto;padding:0 1.25rem;color:#222;background:#fafafa;line-height:1.55}}
h1{{font-size:1.5rem;margin-top:0}}
code{{background:#eee;padding:0.1em 0.4em;border-radius:4px;font-family:ui-monospace,monospace}}
</style></head>
<body>
<h1>Product not found</h1>
<p>No product is registered under the slug <code>{slug_safe}</code>, or it&rsquo;s currently inactive.</p>
<p>If you arrived here from a link the seller shared, double-check that you&rsquo;ve typed the URL correctly. Otherwise, ask the seller to confirm the product slug.</p>
</body></html>"#,
slug_safe = slug_safe
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn format_thousands(n: i64) -> String {
// Renders 50000 as "50,000" — visible price legibility for sat amounts.
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(',');
}
out.push(c);
}
out.chars().rev().collect()
}
+354
View File
@@ -0,0 +1,354 @@
//! Admin endpoints for discount / referral codes.
//!
//! Operators create codes, list them with usage stats, and disable them.
//! The public purchase flow consumes codes via the `code` field on
//! `POST /v1/purchase`; that path is handled in `crate::api::purchase`.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct CreateDiscountCodeReq {
/// e.g. "FOUNDERS50". Normalized to uppercase. ASCII alphanumerics + '-' '_'.
pub code: String,
/// 'percent' | 'fixed_sats'.
pub kind: String,
/// Basis points if kind == 'percent' (0..=10000); sats if kind == 'fixed_sats'.
pub amount: i64,
#[serde(default)]
pub max_uses: Option<i64>,
/// ISO-8601 RFC3339 UTC timestamp.
#[serde(default)]
pub expires_at: Option<String>,
/// Restrict to a single product (by slug). Omit for any product.
#[serde(default)]
pub product_slug: Option<String>,
/// Restrict to a single policy (by slug + product_slug). Omit for any policy.
/// Requires `product_slug` to be set if specified.
#[serde(default)]
pub policy_slug: Option<String>,
/// Optional free-form tag for tracking, e.g. 'launch-twitter', 'alice@example.com'.
#[serde(default)]
pub referrer_label: Option<String>,
#[serde(default)]
pub description: String,
}
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateDiscountCodeReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// Resolve product/policy slugs to ids if supplied.
let product_id = if let Some(slug) = req.product_slug.as_deref() {
let p = repo::get_product_by_slug(&state.db, slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{slug}'")))?;
Some(p.id)
} else {
None
};
let policy_id = if let Some(slug) = req.policy_slug.as_deref() {
let pid = product_id.as_deref().ok_or_else(|| {
AppError::BadRequest("policy_slug requires product_slug".into())
})?;
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{slug}' for product '{}'",
req.product_slug.as_deref().unwrap_or("")
))
})?;
Some(policy.id)
} else {
None
};
let code = repo::create_discount_code(
&state.db,
&req.code,
&req.kind,
req.amount,
req.max_uses,
req.expires_at.as_deref(),
product_id.as_deref(),
policy_id.as_deref(),
req.referrer_label.as_deref(),
&req.description,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"discount_code.create",
Some("discount_code"),
Some(&code.id),
ip.as_deref(),
ua.as_deref(),
&json!({
"code": code.code,
"kind": code.kind,
"amount": code.amount,
"max_uses": code.max_uses,
"expires_at": code.expires_at,
"product_id": product_id,
"policy_id": policy_id,
"referrer_label": code.referrer_label,
}),
)
.await;
Ok(Json(json!(code)))
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub include_inactive: bool,
}
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let codes = repo::list_discount_codes(&state.db, !q.include_inactive).await?;
Ok(Json(json!({ "codes": codes })))
}
pub async fn get_one(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let code = repo::get_discount_code_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("discount code {id}")))?;
let redemptions = repo::list_redemptions_by_code(&state.db, &code.id).await?;
Ok(Json(json!({
"code": code,
"redemptions": redemptions,
})))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_discount_code_active(&state.db, &id, req.active).await?;
let action = if req.active {
"discount_code.enable"
} else {
"discount_code.disable"
};
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
action,
Some("discount_code"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
/// Hard-delete a discount code. Refuses if any redemptions reference
/// the code — those rows are part of the audit trail and shouldn't be
/// orphaned. For codes that have been used, the operator should
/// disable instead.
pub async fn delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// Look up the code so we can audit-log meaningful detail.
let code = repo::get_discount_code_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("discount code '{id}'")))?;
// Refuse if any redemptions exist (referential integrity + audit
// trail preservation). Operator should use Disable in that case.
let redemption_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM discount_redemptions WHERE code_id = ?",
)
.bind(&id)
.fetch_one(&state.db)
.await?;
if redemption_count > 0 {
return Err(AppError::Conflict(format!(
"cannot delete code '{}' — it has {} redemption(s) on the audit trail. \
Disable it instead (it stops accepting new uses, but the history is kept).",
code.code, redemption_count
)));
}
sqlx::query("DELETE FROM discount_codes WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"discount_code.delete",
Some("discount_code"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "code": code.code, "kind": code.kind }),
)
.await;
Ok(Json(json!({ "ok": true, "deleted": code.code })))
}
#[derive(Debug, Deserialize)]
pub struct PreviewQuery {
pub code: String,
pub product: String,
}
/// PUBLIC endpoint — buyers hit this from the buy page when they click
/// Apply on a discount code. Validates the code (existence, active
/// state, expiry, product applicability) and returns the kind +
/// computed discounted price WITHOUT consuming a slot. The actual
/// purchase / redemption still goes through `/v1/purchase` or
/// `/v1/redeem` and is the real transaction; this is just for showing
/// the buyer what they'll be charged before they commit.
pub async fn preview(
State(state): State<AppState>,
Query(q): Query<PreviewQuery>,
) -> AppResult<Json<Value>> {
let code_str = q.code.trim();
if code_str.is_empty() {
return Err(AppError::BadRequest("code is required".into()));
}
let product = repo::get_product_by_slug(&state.db, &q.product)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product)))?;
let code = match repo::get_discount_code_by_code(&state.db, code_str).await? {
Some(c) => c,
None => {
return Ok(Json(json!({
"valid": false,
"reason": "unknown_code",
"message": "Code not found.",
"base_price_sats": product.price_sats,
})));
}
};
if !code.active {
return Ok(Json(json!({
"valid": false,
"reason": "disabled",
"message": "This code has been disabled.",
"base_price_sats": product.price_sats,
})));
}
if let Some(exp) = &code.expires_at {
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
return Ok(Json(json!({
"valid": false,
"reason": "expired",
"message": "This code has expired.",
"base_price_sats": product.price_sats,
})));
}
}
}
if let Some(pid) = &code.applies_to_product_id {
if pid != &product.id {
return Ok(Json(json!({
"valid": false,
"reason": "wrong_product",
"message": "This code does not apply to this product.",
"base_price_sats": product.price_sats,
})));
}
}
if let Some(max) = code.max_uses {
if code.used_count >= max {
return Ok(Json(json!({
"valid": false,
"reason": "exhausted",
"message": "This code has reached its use limit.",
"base_price_sats": product.price_sats,
})));
}
}
// Compute the discounted price (mirroring purchase.rs's logic).
let base = product.price_sats;
let (final_price, discount_applied) = match code.kind.as_str() {
"free_license" => (0i64, base),
"percent" => {
let bps = (code.amount).clamp(0, 10_000) as i128;
let b = base as i128;
let discount = ((b * bps) / 10_000).max(0).min(b) as i64;
((base - discount).max(1), discount)
}
"fixed_sats" => {
let discount = code.amount.max(0).min(base);
((base - discount).max(1), discount)
}
_ => (base, 0),
};
let amount_pct = if code.kind == "percent" {
Some(code.amount as f64 / 100.0)
} else {
None
};
Ok(Json(json!({
"valid": true,
"code": code.code,
"kind": code.kind,
"is_free": code.kind == "free_license",
"base_price_sats": base,
"discount_applied_sats": discount_applied,
"final_price_sats": if code.kind == "free_license" { 0 } else { final_price },
"amount_pct": amount_pct,
"message": match code.kind.as_str() {
"free_license" => "Free license — no payment required.".to_string(),
"percent" => format!("{}% off applied.", code.amount as f64 / 100.0),
"fixed_sats" => format!("{} sats off applied.", code.amount),
_ => "Code applied.".to_string(),
},
})))
}
+150
View File
@@ -0,0 +1,150 @@
//! Admin-only issuer-key import endpoint.
//!
//! Used exactly once, by exactly one operator: when bootstrapping a
//! "master Keysat" instance (the one that issues licenses for the Keysat
//! package itself). The master operator pre-generated an Ed25519 keypair
//! offline; this endpoint takes the PEM-encoded private half and stores
//! it as the daemon's signing keypair, replacing the auto-generated one
//! that gets created on first boot.
//!
//! ## Why this isn't a StartOS Action
//!
//! 95% of Keysat operators install Keysat to sell their own software.
//! Their auto-generated issuer key is exactly what they want; they never
//! need this endpoint. Surfacing an "import issuer key" button in every
//! operator's StartOS Actions tab would create cognitive load (am I
//! supposed to do this?) for zero benefit. So this lives as an admin
//! API endpoint only — invisible by default, callable via curl during
//! the master-bootstrap procedure documented in
//! `MASTER_KEYPAIR_PROCEDURE.md`.
//!
//! ## Safety guards
//!
//! Replacing the issuer key after licenses have been issued would
//! invalidate every previously-signed customer license. To prevent that
//! footgun, the endpoint refuses if any license rows exist in the
//! database. The master Keysat instance hasn't issued anything when it
//! gets bootstrapped, so this guard never trips during legitimate use
//! and prevents the worst-case mistake.
//!
//! ## After successful import
//!
//! The new keypair lands in the `server_keys` table immediately, but the
//! daemon's in-memory `AppState.keypair` still holds the old one until
//! restart. The endpoint returns a `restart_required: true` so the
//! operator (or their orchestration) knows to bounce the service before
//! the new key takes effect.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use axum::{body::Bytes, extract::State, http::HeaderMap, Json};
use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey};
use ed25519_dalek::SigningKey;
use serde_json::{json, Value};
pub async fn import(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let pem = std::str::from_utf8(&body)
.map_err(|_| AppError::BadRequest("body is not valid UTF-8".into()))?
.trim();
if pem.is_empty() {
return Err(AppError::BadRequest("body is empty".into()));
}
if !pem.contains("-----BEGIN") || !pem.contains("PRIVATE KEY-----") {
return Err(AppError::BadRequest(
"expected a PEM-encoded private key (must contain BEGIN/END PRIVATE KEY)".into(),
));
}
// Parse + validate the supplied PEM.
let signing = SigningKey::from_pkcs8_pem(pem).map_err(|e| {
AppError::BadRequest(format!("could not parse Ed25519 private key: {e}"))
})?;
let verifying = signing.verifying_key();
// Re-encode through pkcs8 so we always store a normalized form. This
// also catches any encoding oddity on the input side that would have
// tripped a future load.
use pkcs8::LineEnding;
let priv_pem = signing
.to_pkcs8_pem(LineEnding::LF)
.map_err(|e| AppError::Internal(anyhow::anyhow!("re-encode private key: {e}")))?
.to_string();
let pub_pem = verifying
.to_public_key_pem(LineEnding::LF)
.map_err(|e| AppError::Internal(anyhow::anyhow!("encode public key: {e}")))?;
// Safety guard: refuse if any licenses have already been issued by
// this Keysat. Replacing the issuer key would invalidate them.
let licenses_exist: bool =
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM licenses LIMIT 1)")
.fetch_one(&state.db)
.await?;
if licenses_exist {
return Err(AppError::Conflict(
"this Keysat has already issued at least one license; importing a new \
issuer key would invalidate every previously-signed license. Refusing. \
Use this endpoint only on a fresh master-Keysat install before any \
licenses have been issued."
.into(),
));
}
// Upsert the keypair into server_keys row id=1. SQLite's INSERT ON
// CONFLICT is the idiomatic way to do this in one statement.
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO server_keys (id, algorithm, public_key_pem, private_key_pem, created_at)
VALUES (1, 'ed25519', ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
algorithm = excluded.algorithm,
public_key_pem = excluded.public_key_pem,
private_key_pem = excluded.private_key_pem,
created_at = excluded.created_at",
)
.bind(&pub_pem)
.bind(&priv_pem)
.bind(&now)
.execute(&state.db)
.await?;
// Audit-log this prominently. There is no scenario where a regular
// operator should be running this; if it shows up in the audit log
// unexpectedly, that's a red flag worth investigating.
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"issuer_key.import",
Some("server_key"),
None,
ip.as_deref(),
ua.as_deref(),
&json!({
"public_key_pem": pub_pem,
"note": "master-bootstrap import",
}),
)
.await;
tracing::warn!(
public_key = %pub_pem.lines().nth(1).unwrap_or(""),
"issuer key imported via admin endpoint — restart the service for the new key to take effect"
);
Ok(Json(json!({
"ok": true,
"public_key_pem": pub_pem,
"restart_required": true,
"message": "Issuer key imported. Restart the Keysat service for the new \
key to take effect — until then, in-memory state still holds \
the previous keypair."
})))
}
+306
View File
@@ -0,0 +1,306 @@
//! Machines — individual install records bound to a license.
//!
//! In the single-seat case (`licenses.max_machines = 1`), the first
//! successful `/v1/validate` call locks the fingerprint onto the license
//! and creates a `machines` row. Later validations keep heartbeating that
//! row.
//!
//! In the multi-seat case (`max_machines > 1` or `0` for unlimited),
//! validate auto-activates up to the cap. Beyond the cap, the client gets a
//! `too_many_machines` reject and is expected to call
//! `POST /v1/machines/deactivate` with the fingerprint of an old install to
//! free up a slot, then retry.
//!
//! Explicit activation endpoints (`POST /v1/machines/activate`) are offered
//! for apps that want to prompt the user about seat usage before starting up
//! for the first time. They behave identically to `/v1/validate`'s implicit
//! activation, just without requiring the full key check.
//!
//! Admin endpoints let operators look at who's using what and force-kick a
//! machine off a license.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::crypto;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
// ---------- Public endpoints (client-facing) ----------
#[derive(Debug, Deserialize)]
pub struct ActivateReq {
pub key: String,
pub fingerprint: String,
pub hostname: Option<String>,
pub platform: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ActivateResp {
pub ok: bool,
pub machine_id: Option<String>,
pub active_count: i64,
pub max_machines: i64,
pub reason: Option<String>,
}
pub async fn activate(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<ActivateReq>,
) -> AppResult<Json<ActivateResp>> {
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
let license_id = payload.license_id.to_string();
let license = repo::get_license_by_id(&state.db, &license_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("license {license_id}")))?;
if license.status != "active" {
return Ok(Json(ActivateResp {
ok: false,
machine_id: None,
active_count: 0,
max_machines: license.max_machines,
reason: Some(license.status),
}));
}
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
let fp_hash = crate::hex_sha256(&req.fingerprint);
if let Some(m) =
repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?
{
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
let active = repo::list_active_machines(&state.db, &license_id).await?;
return Ok(Json(ActivateResp {
ok: true,
machine_id: Some(m.id),
active_count: active.len() as i64,
max_machines: license.max_machines,
reason: None,
}));
}
let active = repo::list_active_machines(&state.db, &license_id).await?;
if license.max_machines > 0 && active.len() as i64 >= license.max_machines {
return Ok(Json(ActivateResp {
ok: false,
machine_id: None,
active_count: active.len() as i64,
max_machines: license.max_machines,
reason: Some("too_many_machines".into()),
}));
}
let m = repo::activate_machine(
&state.db,
&license_id,
&req.fingerprint,
&fp_hash,
req.hostname.as_deref(),
req.platform.as_deref(),
client_ip.as_deref(),
)
.await?;
crate::webhooks::dispatch(
&state,
"machine.activated",
&json!({
"license_id": license_id,
"machine_id": m.id,
"fingerprint_hash": fp_hash,
}),
)
.await;
let active = repo::list_active_machines(&state.db, &license_id).await?;
Ok(Json(ActivateResp {
ok: true,
machine_id: Some(m.id),
active_count: active.len() as i64,
max_machines: license.max_machines,
reason: None,
}))
}
#[derive(Debug, Deserialize)]
pub struct HeartbeatReq {
pub key: String,
pub fingerprint: String,
}
pub async fn heartbeat(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<HeartbeatReq>,
) -> AppResult<Json<Value>> {
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
let license_id = payload.license_id.to_string();
// Rate-limit heartbeats per-license to 60/hr.
if !crate::rate_limit::consume(
&state.db,
"heartbeat_license",
&license_id,
/* capacity */ 60.0,
/* refill_per_second */ 60.0 / 3600.0,
)
.await?
{
return Ok(Json(json!({ "ok": false, "reason": "rate_limited" })));
}
let fp_hash = crate::hex_sha256(&req.fingerprint);
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
match repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await? {
Some(m) => {
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
Ok(Json(json!({ "ok": true, "machine_id": m.id })))
}
None => Ok(Json(json!({ "ok": false, "reason": "not_activated" }))),
}
}
#[derive(Debug, Deserialize)]
pub struct DeactivateReq {
pub key: String,
pub fingerprint: String,
#[serde(default)]
pub reason: Option<String>,
}
pub async fn deactivate(
State(state): State<AppState>,
Json(req): Json<DeactivateReq>,
) -> AppResult<Json<Value>> {
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
let license_id = payload.license_id.to_string();
let fp_hash = crate::hex_sha256(&req.fingerprint);
let m = repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?;
let Some(m) = m else {
return Ok(Json(json!({ "ok": false, "reason": "not_found" })));
};
let reason = req
.reason
.unwrap_or_else(|| "client_requested".to_string());
repo::deactivate_machine(&state.db, &m.id, &reason).await?;
crate::webhooks::dispatch(
&state,
"machine.deactivated",
&json!({
"license_id": license_id,
"machine_id": m.id,
"reason": reason,
}),
)
.await;
// Single-seat legacy: also clear licenses.fingerprint so the next client
// can re-bind cleanly.
let license = repo::get_license_by_id(&state.db, &license_id).await?;
if let Some(lic) = license {
if lic.max_machines == 1 {
let _ = sqlx::query("UPDATE licenses SET fingerprint = NULL WHERE id = ?")
.bind(&license_id)
.execute(&state.db)
.await;
}
}
Ok(Json(json!({ "ok": true })))
}
// ---------- Admin endpoints ----------
#[derive(Debug, Deserialize)]
pub struct AdminListQuery {
pub license_id: String,
#[serde(default)]
pub include_inactive: bool,
}
pub async fn admin_list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<AdminListQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let machines = if q.include_inactive {
repo::list_all_machines(&state.db, &q.license_id).await?
} else {
repo::list_active_machines(&state.db, &q.license_id).await?
};
Ok(Json(json!({ "machines": machines })))
}
#[derive(Debug, Deserialize)]
pub struct AdminDeactivateReq {
#[serde(default)]
pub reason: String,
}
pub async fn admin_deactivate(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<AdminDeactivateReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin deactivate".to_string()
} else {
req.reason
};
let m = repo::get_machine_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("machine {id}")))?;
repo::deactivate_machine(&state.db, &id, &reason).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"machine.deactivate",
Some("machine"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "license_id": m.license_id, "reason": reason }),
)
.await;
crate::webhooks::dispatch(
&state,
"machine.deactivated",
&json!({
"license_id": m.license_id,
"machine_id": id,
"reason": reason,
}),
)
.await;
Ok(Json(json!({ "ok": true })))
}
+666
View File
@@ -0,0 +1,666 @@
//! 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 |
//! | 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 |
//! | 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 |
pub mod admin;
pub mod admin_ui;
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 validate;
pub mod webhook;
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<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>>,
}
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;
}
}
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))
// 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/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/: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/active",
patch(policies::set_active),
)
.route(
"/v1/admin/policies/:id/tip",
patch(policies::set_tip),
)
.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),
)
// 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).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))
.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_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#"<!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;
}}
.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); }}
</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&hellip;</h1>
<p class="lede" id="page-lede">Your Bitcoin payment was received. We&rsquo;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.</p>
<!-- pending state (default): polling for the license -->
<div class="pending-card" id="pending-card">
<div class="stamp">&mdash; Awaiting confirmation &mdash;</div>
<h2>Hang tight.</h2>
<p class="sub">This page will refresh automatically when your license is ready.</p>
<div class="spinner" aria-hidden="true"></div>
<div class="status-detail" id="status-detail">checking status&hellip;</div>
</div>
<!-- success state: license card -->
<div class="license-success hide" id="license-success" role="region" aria-label="License issued">
<div class="stamp">&mdash; License issued &mdash;</div>
<h2>You&rsquo;re licensed.</h2>
<p class="sub">Your signed license is below. We&rsquo;ll also email a copy.</p>
<div class="field-label">License key</div>
<div class="key-box">
<span class="key-text" id="license-key-text">&hellip;</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&rsquo;s public key. You don&rsquo;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> &middot; Bitcoin-paid software licensing</span>
</footer>
<script>
(function() {{
const INVOICE_ID = {invoice_id_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');
// 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 sellers public key. You can close this tab when youre 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.';
}}
let attempt = 0;
const MAX_ATTEMPTS = 240; // 240 * 3s = 12 min total. Most settle inside 1.
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 = 'invoice status: ' + status + (attempt > 1 ? ' (still polling)' : '');
if (status === 'expired' || status === 'invalid') {{
return showError('Payment was not completed (status: ' + status + '). If you sent funds, contact the seller.');
}}
scheduleNext();
}} catch (err) {{
statusDetail.textContent = 'network error (retrying): ' + (err.message || err);
scheduleNext();
}}
}}
function scheduleNext() {{
if (attempt >= MAX_ATTEMPTS) {{
statusDetail.textContent = 'still waiting — refresh the page or come back later.';
return;
}}
setTimeout(poll, 3000);
}}
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 {
'&' => "&amp;".to_string(),
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
'\'' => "&#39;".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,
}))
}
+270
View File
@@ -0,0 +1,270 @@
//! Policies — reusable license templates.
//!
//! A policy captures "when I issue a license under this shape, what are the
//! defaults?" (duration, grace period, entitlements, machine cap, trial flag,
//! optional price override). Callers to `/v1/admin/licenses` can reference a
//! policy by slug instead of specifying every field.
//!
//! Policies are per-product. The system looks up a "default" policy for a
//! product when a customer buys it through the normal purchase flow — so most
//! products should have at least one policy slugged `default`.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct CreatePolicyReq {
pub product_slug: String,
pub name: String,
pub slug: String,
/// 0 = perpetual.
#[serde(default)]
pub duration_seconds: i64,
#[serde(default)]
pub grace_seconds: i64,
/// 0 = unlimited, 1 = single-seat, n>1 = n-seat.
#[serde(default = "default_max_machines")]
pub max_machines: i64,
#[serde(default)]
pub is_trial: bool,
#[serde(default)]
pub price_sats_override: Option<i64>,
#[serde(default)]
pub entitlements: Vec<String>,
#[serde(default)]
pub metadata: Value,
/// Optional Lightning recipient (e.g. "tip@keysat.xyz") to tip a percentage
/// of each successful issuance to. None = no tipping.
#[serde(default)]
pub tip_recipient: Option<String>,
/// Tip percentage in basis points. 100 = 1%. Capped at 10000 (=100%).
#[serde(default)]
pub tip_pct_bps: i64,
/// Free-form label for the tip recipient (audit/UI).
#[serde(default)]
pub tip_label: Option<String>,
}
fn default_max_machines() -> i64 {
1
}
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreatePolicyReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product_slug)))?;
if req.duration_seconds < 0 {
return Err(AppError::BadRequest("duration_seconds must be >= 0".into()));
}
if req.grace_seconds < 0 {
return Err(AppError::BadRequest("grace_seconds must be >= 0".into()));
}
if req.max_machines < 0 {
return Err(AppError::BadRequest("max_machines must be >= 0".into()));
}
let metadata = if req.metadata.is_null() {
json!({})
} else {
req.metadata
};
if req.tip_pct_bps < 0 || req.tip_pct_bps > 10_000 {
return Err(AppError::BadRequest(
"tip_pct_bps must be between 0 and 10000 (100%)".into(),
));
}
let tip_recipient = req.tip_recipient.as_deref().filter(|s| !s.trim().is_empty());
if tip_recipient.is_some() && req.tip_pct_bps == 0 {
return Err(AppError::BadRequest(
"tip_pct_bps must be > 0 when tip_recipient is set".into(),
));
}
if tip_recipient.is_none() && req.tip_pct_bps > 0 {
return Err(AppError::BadRequest(
"tip_recipient must be set when tip_pct_bps > 0".into(),
));
}
let tip_label = req.tip_label.as_deref().filter(|s| !s.trim().is_empty());
let policy = repo::create_policy(
&state.db,
&product.id,
&req.name,
&req.slug,
req.duration_seconds,
req.grace_seconds,
req.max_machines,
req.is_trial,
req.price_sats_override,
&req.entitlements,
&metadata,
tip_recipient,
req.tip_pct_bps,
tip_label,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.create",
Some("policy"),
Some(&policy.id),
ip.as_deref(),
ua.as_deref(),
&json!({ "product_id": product.id, "slug": policy.slug }),
)
.await;
Ok(Json(json!(policy)))
}
#[derive(Debug, Deserialize)]
pub struct ListPoliciesQuery {
pub product_slug: String,
#[serde(default)]
pub include_inactive: bool,
}
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListPoliciesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let product = repo::get_product_by_slug(&state.db, &q.product_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product_slug)))?;
let rows = repo::list_policies_by_product(&state.db, &product.id, !q.include_inactive).await?;
Ok(Json(json!({ "policies": rows })))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_policy_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.set_active",
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
#[derive(Debug, Deserialize)]
pub struct SetTipReq {
/// Lightning Address (`user@domain`). Pass `null` to disable tipping.
pub tip_recipient: Option<String>,
/// Basis points: 010000. 0 = disabled.
pub tip_pct_bps: i64,
/// Optional free-form label (audit / UI).
#[serde(default)]
pub tip_label: Option<String>,
}
pub async fn set_tip(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetTipReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if req.tip_pct_bps < 0 || req.tip_pct_bps > 10_000 {
return Err(AppError::BadRequest(
"tip_pct_bps must be between 0 and 10000".into(),
));
}
let recipient = req.tip_recipient.as_deref().filter(|s| !s.trim().is_empty());
if recipient.is_some() && req.tip_pct_bps == 0 {
return Err(AppError::BadRequest(
"tip_pct_bps must be > 0 when tip_recipient is set".into(),
));
}
if recipient.is_none() && req.tip_pct_bps > 0 {
return Err(AppError::BadRequest(
"tip_recipient must be set when tip_pct_bps > 0".into(),
));
}
let label = req.tip_label.as_deref().filter(|s| !s.trim().is_empty());
let updated =
repo::set_policy_tip_config(&state.db, &id, recipient, req.tip_pct_bps, label).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.set_tip",
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"tip_recipient": updated.tip_recipient,
"tip_pct_bps": updated.tip_pct_bps,
"tip_label": updated.tip_label,
}),
)
.await;
Ok(Json(json!(updated)))
}
#[derive(Debug, Deserialize)]
pub struct ListTipsQuery {
#[serde(default)]
pub license_id: Option<String>,
#[serde(default)]
pub recipient: Option<String>,
#[serde(default = "default_tip_limit")]
pub limit: i64,
}
fn default_tip_limit() -> i64 {
100
}
pub async fn list_tips(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListTipsQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let entries = repo::list_tip_attempts(
&state.db,
q.license_id.as_deref(),
q.recipient.as_deref(),
q.limit,
)
.await?;
Ok(Json(json!({ "tips": entries })))
}
+25
View File
@@ -0,0 +1,25 @@
//! Public product endpoints.
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, State},
Json,
};
use serde_json::{json, Value};
pub async fn list(State(state): State<AppState>) -> AppResult<Json<Value>> {
let products = repo::list_products(&state.db, true).await?;
Ok(Json(json!({ "products": products })))
}
pub async fn get(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> AppResult<Json<Value>> {
let product = repo::get_product_by_slug(&state.db, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{slug}'")))?;
Ok(Json(json!(product)))
}
+332
View File
@@ -0,0 +1,332 @@
//! Purchase flow:
//! 1. Client POSTs `/v1/purchase` with a product slug.
//! 2. We create a BTCPay invoice, stash a row, return the checkout URL.
//! 3. Client opens the URL, pays. BTCPay hits our webhook (see
//! [`crate::api::webhook`]) which marks the invoice 'settled' and
//! issues a license.
//! 4. Client polls `/v1/purchase/:invoice_id` until `license_key` is
//! present, then stores it locally.
use crate::api::AppState;
use crate::btcpay::client::BtcpayClient;
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct StartPurchaseReq {
/// Product slug to buy.
pub product: String,
/// Optional email for receipt / future contact.
pub buyer_email: Option<String>,
/// Optional free-text note from the buyer.
pub buyer_note: Option<String>,
/// Optional URL the buyer should be returned to after payment.
pub redirect_url: Option<String>,
/// Optional discount / referral code (case-insensitive).
pub code: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct StartPurchaseResp {
pub invoice_id: String, // our internal id
pub btcpay_invoice_id: String, // BTCPay's id (for debugging)
pub checkout_url: String, // URL the user opens to pay
pub amount_sats: i64, // what BTCPay was charged (post-discount)
pub base_price_sats: i64, // product list price (pre-discount)
pub discount_applied_sats: i64, // base - amount_sats; 0 if no code
pub poll_url: String, // where to check status
}
/// Floor for invoiced amount after a discount is applied. Set to 1 sat so
/// 100%-off codes still produce a real BTCPay invoice (and the buyer
/// experiences the purchase flow). 0-sat invoices aren't always supported
/// by BTCPay anyway.
const MIN_INVOICE_SATS: i64 = 1;
pub async fn start(
State(state): State<AppState>,
Json(req): Json<StartPurchaseReq>,
) -> AppResult<Json<StartPurchaseResp>> {
let product = repo::get_product_by_slug(&state.db, &req.product)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product)))?;
if !product.active {
return Err(AppError::BadRequest(format!(
"product '{}' is not available for purchase",
req.product
)));
}
let base_price = product.price_sats;
// Resolve and validate the discount code if one was supplied. The
// ordering here matters: we must atomically reserve a counter slot
// BEFORE we create the BTCPay invoice, so that a code-cap race can't
// result in a buyer holding a discounted live invoice for an
// already-exhausted code.
//
// step A: lookup + eligibility checks (active, expired, applies-to)
// step B: atomically increment used_count (try_reserve_code_slot)
// step C: compute discount, create BTCPay invoice
// step D: persist local invoice
// step E: insert the pending redemption row (record_pending_redemption)
//
// If C, D, or E fail after B succeeded, we call release_code_slot to
// give the slot back.
let (final_price, reservation, discount_applied) = if let Some(raw_code) =
req.code.as_deref().filter(|s| !s.trim().is_empty())
{
let code = repo::get_discount_code_by_code(&state.db, raw_code)
.await?
.ok_or_else(|| AppError::BadRequest("unknown discount code".into()))?;
if !code.active {
return Err(AppError::BadRequest("discount code is disabled".into()));
}
if let Some(exp) = &code.expires_at {
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
return Err(AppError::BadRequest("discount code has expired".into()));
}
}
}
if let Some(pid) = &code.applies_to_product_id {
if pid != &product.id {
return Err(AppError::BadRequest(
"discount code does not apply to this product".into(),
));
}
}
// Note: applies_to_policy_id is informational in v0.1 — the
// policy used at license-issuance time is the product's default.
// Step B: atomic reserve.
repo::try_reserve_code_slot(&state.db, &code.id).await?;
let discount = compute_discount(&code.kind, code.amount, base_price);
let final_price = (base_price - discount).max(MIN_INVOICE_SATS);
(final_price, Some(code), discount)
} else {
(base_price, None, 0)
};
// Pre-allocate an internal invoice id so we can pass it to BTCPay as
// metadata, letting us correlate webhook events back to our row even
// before we've persisted the BTCPay invoice id.
let internal_id = uuid::Uuid::new_v4().to_string();
// If the caller didn't supply a redirect_url, default to our own
// /thank-you page with the invoice id baked in. After payment
// BTCPay sends the buyer's browser there; the page polls
// /v1/purchase/<invoice_id> until the license is issued, then
// renders it. Internal ID (UUID) goes in the URL so the buyer can
// bookmark it / refresh later if they close the tab.
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
state.config.public_base_url, internal_id
);
let redirect_url = req
.redirect_url
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&default_redirect);
let metadata = BtcpayClient::invoice_metadata(&product.id, &internal_id);
let btcpay = match state.btcpay_client().await {
Ok(c) => c,
Err(e) => {
// Release the reserved slot if we have one — BTCPay isn't ready.
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(e);
}
};
// Step C: BTCPay invoice. On failure, release the slot and bail.
let created = match btcpay
.create_invoice(final_price, metadata, Some(redirect_url))
.await
{
Ok(c) => c,
Err(e) => {
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(AppError::Upstream(format!(
"BTCPay invoice create failed: {e}"
)));
}
};
// BTCPay returns a checkout URL using whatever URL we called its
// API at — for us, the internal Docker hostname (fast). Rewrite
// the host to the configured public URL so the buyer actually
// gets a link they can open. Falls through unchanged if no public
// URL is configured (test/dev only).
let checkout_url = match &state.config.btcpay_public_url {
Some(public_base) => {
let rewritten =
crate::payment::btcpay::rewrite_to_public(&created.checkout_link, public_base);
tracing::info!(
original = %created.checkout_link,
rewritten = %rewritten,
public_base = %public_base,
"purchase: checkout URL rewritten for buyer"
);
rewritten
}
None => {
tracing::warn!(
original = %created.checkout_link,
"purchase: checkout URL NOT rewritten — btcpay_public_url is None"
);
created.checkout_link.clone()
}
};
// Step D: persist local invoice. On failure, release the slot.
// Use internal_id we pre-generated (and baked into the BTCPay
// redirect_url) as the local row id so /v1/purchase/<id> and
// /thank-you?invoice_id=<id> all resolve to the same row.
let invoice = match repo::create_invoice(
&state.db,
&internal_id,
&created.id,
&product.id,
final_price,
&checkout_url,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
)
.await
{
Ok(inv) => inv,
Err(e) => {
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(e);
}
};
// Step E: persist the redemption row tying the slot to the invoice.
if let Some(code) = &reservation {
if let Err(e) = repo::record_pending_redemption(
&state.db,
&code.id,
&invoice.id,
discount_applied,
base_price,
final_price,
)
.await
{
// Slot was reserved but we couldn't record the redemption.
// Release the slot and mark the BTCPay invoice as invalid
// locally so we don't accidentally honour it on settle.
tracing::error!(
code = %code.code,
invoice_id = %invoice.id,
error = %e,
"failed to persist pending redemption; releasing slot \
and invalidating local invoice"
);
let _ = repo::release_code_slot(&state.db, &code.id).await;
let _ = repo::update_invoice_status(&state.db, &created.id, "invalid").await;
return Err(e);
}
}
let poll_url = format!("{}/v1/purchase/{}", state.config.public_base_url, invoice.id);
Ok(Json(StartPurchaseResp {
invoice_id: invoice.id,
btcpay_invoice_id: created.id,
checkout_url,
amount_sats: final_price,
base_price_sats: base_price,
discount_applied_sats: discount_applied,
poll_url,
}))
}
/// Apply the discount math. Returns the sats to subtract from `base`.
/// Caller is responsible for clamping the result (and for floor enforcement).
fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 {
match kind {
"percent" => {
// amount is basis points (0..=10000). 5000 == 50%.
// Multiply in i128 to avoid overflow on large sat amounts.
let bps = amount.clamp(0, 10_000) as i128;
let base = base_price_sats as i128;
((base * bps) / 10_000).max(0).min(base) as i64
}
"fixed_sats" => amount.max(0).min(base_price_sats),
_ => 0,
}
}
/// Polling endpoint — returns status; if settled and a license has been
/// issued, returns the signed key string.
pub async fn status(
State(state): State<AppState>,
Path(invoice_id): Path<String>,
) -> AppResult<Json<Value>> {
let invoice = repo::get_invoice_by_id(&state.db, &invoice_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("invoice '{invoice_id}'")))?;
let license = repo::get_license_by_invoice(&state.db, &invoice.id).await?;
let license_key = match &license {
Some(lic) if lic.status == "active" => {
// Re-issue the encoded key deterministically from the stored
// license row. `issued_at` is parseable as RFC3339; we reduce to
// unix seconds. Fingerprint binding isn't done here because the
// key is still unbound at first delivery — it'll be bound the
// first time the app calls /v1/validate or /v1/machines/activate.
let flags = if lic.is_trial { FLAG_TRIAL } else { 0 };
let expires_at = lic
.expires_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.timestamp())
.unwrap_or(0);
let payload = LicensePayload {
version: KEY_VERSION_V2,
flags,
product_id: uuid::Uuid::parse_str(&lic.product_id).map_err(|e| {
AppError::Internal(anyhow::anyhow!("bad stored product_id: {e}"))
})?,
license_id: uuid::Uuid::parse_str(&lic.id).map_err(|e| {
AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}"))
})?,
issued_at: chrono::DateTime::parse_from_rfc3339(&lic.issued_at)
.map(|t| t.timestamp())
.unwrap_or(0),
expires_at,
fingerprint_hash: [0u8; 32],
entitlements: lic.entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
Some(encode_key(&payload, &sig))
}
_ => None,
};
Ok(Json(json!({
"invoice_id": invoice.id,
"status": invoice.status,
"product_id": invoice.product_id,
"amount_sats": invoice.amount_sats,
"license_key": license_key,
"license_id": license.as_ref().map(|l| l.id.clone()),
})))
}
+192
View File
@@ -0,0 +1,192 @@
//! Free-license code redemption — the no-BTCPay path.
//!
//! Flow for `kind = 'free_license'` codes:
//! 1. Buyer hits POST /v1/redeem with `{product, code, buyer_email?, buyer_note?}`.
//! 2. Server validates the code (active, not expired, applies-to, kind == free_license).
//! 3. Server atomically reserves a slot (try_reserve_code_slot).
//! 4. Server synthesizes a settled invoice with amount_sats = 0
//! (so the rest of the data model — license → invoice — stays uniform).
//! 5. Server records the pending redemption row.
//! 6. Server calls the existing `issue_license_for_invoice` path which:
//! - issues the license,
//! - fires `license.issued`,
//! - finalizes the redemption (pending → redeemed),
//! - fires `code.redeemed`.
//! 7. Response includes the signed license_key so the buyer can paste it
//! directly into your app — no polling, no BTCPay.
use crate::api::AppState;
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct RedeemReq {
/// Product slug.
pub product: String,
/// Redeemable code (case-insensitive).
pub code: String,
/// Optional email — recorded on the synthetic invoice and license for
/// admin search and webhook payloads.
pub buyer_email: Option<String>,
/// Optional free-text note (recorded on invoice).
pub buyer_note: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct RedeemResp {
pub license_id: String,
pub license_key: String,
pub invoice_id: String,
pub redemption_id: String,
}
pub async fn redeem(
State(state): State<AppState>,
Json(req): Json<RedeemReq>,
) -> AppResult<Json<RedeemResp>> {
let product = repo::get_product_by_slug(&state.db, &req.product)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product)))?;
if !product.active {
return Err(AppError::BadRequest(format!(
"product '{}' is not available for redemption",
req.product
)));
}
if req.code.trim().is_empty() {
return Err(AppError::BadRequest("code is required".into()));
}
let code = repo::get_discount_code_by_code(&state.db, &req.code)
.await?
.ok_or_else(|| AppError::BadRequest("unknown code".into()))?;
if !code.active {
return Err(AppError::BadRequest("code is disabled".into()));
}
if code.kind != "free_license" {
return Err(AppError::BadRequest(
"this code requires payment — use the standard purchase flow with the code applied".into(),
));
}
if let Some(exp) = &code.expires_at {
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
return Err(AppError::BadRequest("code has expired".into()));
}
}
}
if let Some(pid) = &code.applies_to_product_id {
if pid != &product.id {
return Err(AppError::BadRequest(
"code does not apply to this product".into(),
));
}
}
// Atomic reserve. If reserved succeeds and any subsequent step fails,
// we release the slot so a freed slot becomes available again.
repo::try_reserve_code_slot(&state.db, &code.id).await?;
// Synthesize a settled, zero-amount invoice. Errors release the slot.
let invoice = match repo::create_free_invoice(
&state.db,
&product.id,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
)
.await
{
Ok(inv) => inv,
Err(e) => {
let _ = repo::release_code_slot(&state.db, &code.id).await;
return Err(e);
}
};
// Record the pending redemption row tying the slot to this invoice.
if let Err(e) = repo::record_pending_redemption(
&state.db,
&code.id,
&invoice.id,
0, // discount_applied (whole price is "free")
0, // base_price_sats (free)
0, // final_price_sats
)
.await
{
let _ = repo::release_code_slot(&state.db, &code.id).await;
return Err(e);
}
// Issue the license. This also finalizes the redemption (pending →
// redeemed) and fires both `license.issued` and `code.redeemed`
// outbound webhooks.
let license_id = match crate::api::webhook::issue_license_for_invoice(&state, &invoice).await {
Ok(id) => id,
Err(e) => {
// The invoice + redemption are persisted but the license
// failed. Cancel the redemption so the slot is released and
// log loudly.
tracing::error!(
code = %code.code,
invoice_id = %invoice.id,
error = %e,
"free redemption: license issuance failed after invoice + redemption \
were persisted"
);
if let Ok(Some(red)) =
repo::get_pending_redemption_by_invoice(&state.db, &invoice.id).await
{
let _ = repo::cancel_redemption(&state.db, &red.id).await;
}
return Err(e);
}
};
// Re-derive the signed license key so we can return it to the buyer
// directly. Mirrors the math in `purchase::status`.
let license = repo::get_license_by_invoice(&state.db, &invoice.id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("license vanished after issue")))?;
let flags = if license.is_trial { FLAG_TRIAL } else { 0 };
let expires_at_unix = license
.expires_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.with_timezone(&chrono::Utc).timestamp())
.unwrap_or(0);
let payload = LicensePayload {
version: KEY_VERSION_V2,
flags,
product_id: uuid::Uuid::parse_str(&license.product_id)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored product_id: {e}")))?,
license_id: uuid::Uuid::parse_str(&license.id)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}")))?,
issued_at: chrono::DateTime::parse_from_rfc3339(&license.issued_at)
.map(|t| t.timestamp())
.unwrap_or(0),
expires_at: expires_at_unix,
fingerprint_hash: [0u8; 32],
entitlements: license.entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
let license_key = encode_key(&payload, &sig);
// The redemption row was finalized inside issue_license_for_invoice;
// re-fetch to surface its id in the response.
let redemption_id = repo::list_redemptions_by_code(&state.db, &code.id)
.await
.ok()
.and_then(|rows| rows.into_iter().find(|r| r.invoice_id == invoice.id).map(|r| r.id))
.unwrap_or_default();
Ok(Json(RedeemResp {
license_id,
license_key,
invoice_id: invoice.id,
redemption_id,
}))
}
+172
View File
@@ -0,0 +1,172 @@
//! Admin endpoints for managing the daemon's own self-license
//! (Keysat-licenses-Keysat).
//!
//! - `GET /v1/admin/self-license` — current tier (licensed / unlicensed)
//! - `POST /v1/admin/self-license` — activate a new license. Validates
//! against the embedded master pubkey, writes the file to
//! `SELF_LICENSE_PATH`, and swaps the runtime tier in app state.
//!
//! These run *only* when authenticated by the admin API key — same gate
//! as every other `/v1/admin/*` route.
use crate::api::AppState;
use crate::error::AppResult;
use crate::license_self::{self, Tier};
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
#[serde(tag = "tier", rename_all = "snake_case")]
pub enum TierStatus {
Unlicensed {
reason: String,
mode: &'static str,
},
Licensed {
license_id: String,
product_id: String,
/// Unix seconds; 0 means perpetual.
expires_at: i64,
entitlements: Vec<String>,
mode: &'static str,
},
}
fn tier_to_status(tier: &Tier) -> TierStatus {
let mode = match license_self::mode() {
license_self::Mode::Permissive => "permissive",
license_self::Mode::Enforce => "enforce",
};
match tier {
Tier::Unlicensed { reason } => TierStatus::Unlicensed {
reason: reason.clone(),
mode,
},
Tier::Licensed {
license_id,
product_id,
expires_at,
entitlements,
} => TierStatus::Licensed {
license_id: license_id.to_string(),
product_id: product_id.to_string(),
expires_at: *expires_at,
entitlements: entitlements.clone(),
mode,
},
}
}
pub async fn status(State(state): State<AppState>) -> Json<TierStatus> {
let tier = state.self_tier.read().await.clone();
Json(tier_to_status(&tier))
}
#[derive(Deserialize)]
pub struct ActivateBody {
pub license_key: String,
}
#[derive(Serialize)]
pub struct ActivateResponse {
pub ok: bool,
pub tier: TierStatus,
pub message: String,
}
pub async fn activate(
State(state): State<AppState>,
Json(body): Json<ActivateBody>,
) -> AppResult<impl IntoResponse> {
let key = body.license_key.trim().to_string();
if key.is_empty() {
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "license_key is required"
})),
)
.into_response());
}
// Verify against the embedded master pubkey before persisting.
let new_tier = match license_self::verify_license(&key) {
Ok(t) => t,
Err(e) => {
tracing::warn!("self-license activation rejected: {e:#}");
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "license_invalid",
"detail": format!("{e:#}"),
})),
)
.into_response());
}
};
// Persist to /data/keysat-license.txt.
if let Err(e) = license_self::write_license_file(&key) {
tracing::error!("self-license file write failed: {e:#}");
return Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "write_failed",
"detail": format!("{e:#}"),
})),
)
.into_response());
}
// Swap the runtime tier.
{
let mut guard = state.self_tier.write().await;
*guard = new_tier.clone();
}
let status_resp = tier_to_status(&new_tier);
let summary = match &status_resp {
TierStatus::Licensed {
license_id,
expires_at,
entitlements,
..
} => {
let exp = if *expires_at == 0 {
"perpetual".to_string()
} else {
format!("expires unix={}", expires_at)
};
let ents = if entitlements.is_empty() {
"(none)".to_string()
} else {
entitlements.join(",")
};
format!(
"License {} verified — {}, entitlements={}.",
license_id, exp, ents
)
}
TierStatus::Unlicensed { .. } => {
// Should be unreachable; verify_license never returns Unlicensed.
"License processed.".to_string()
}
};
tracing::info!("self-license activated: {summary}");
Ok((
StatusCode::OK,
Json(ActivateResponse {
ok: true,
tier: status_resp,
message: summary,
}),
)
.into_response())
}
+451
View File
@@ -0,0 +1,451 @@
//! The single most-hit endpoint: validate a license key.
//!
//! Clients — typically another piece of software starting up — call this
//! with their key and (optionally) the `product_slug` they expect the key
//! to cover and a `fingerprint` identifying the machine/installation.
//!
//! Response shape (HTTP always 200; `ok` + `reason` machine-readable):
//!
//! ```json
//! { "ok": true, "license_id": "...", "product_id": "...", "entitlements": ["pro"], "status": "active" }
//! { "ok": false, "reason": "expired", "grace_until": "..." }
//! ```
//!
//! Machine cap handling:
//!
//! When a license allows more than one concurrent machine (`max_machines != 1`),
//! validate will auto-activate up to the cap. Beyond the cap, the call is
//! rejected with `too_many_machines` — the client is expected to either
//! prompt the user to deactivate another machine or to call
//! `POST /v1/machines/deactivate` first. `max_machines == 0` means unlimited.
use crate::api::AppState;
use crate::crypto::{self, hash_fingerprint};
use crate::db::repo;
use crate::error::AppResult;
use axum::{
extract::State,
http::{header, HeaderMap},
Json,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct ValidateReq {
pub key: String,
/// Optional: the product slug the caller expects this key to cover.
/// Rejects keys issued for a different product even if valid.
pub product_slug: Option<String>,
/// Optional: raw machine fingerprint. First successful validation binds
/// this to the license row (if not already set); later validations
/// succeed only if it matches.
pub fingerprint: Option<String>,
/// Optional client-supplied hostname for machine records.
pub hostname: Option<String>,
/// Optional client-supplied platform descriptor.
pub platform: Option<String>,
}
#[derive(Debug, Serialize, Default)]
pub struct ValidateResp {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product_slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issued_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grace_until: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_grace_period: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_trial: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
pub entitlements: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub machine_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_machines: Option<i64>,
}
fn reject(reason: &str) -> ValidateResp {
ValidateResp {
ok: false,
reason: Some(reason.to_string()),
..Default::default()
}
}
pub async fn validate(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<ValidateReq>,
) -> AppResult<Json<ValidateResp>> {
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Rate limit by client IP if available, else by license key prefix as a
// last-ditch bucket key. Cap at 60 req / minute / bucket.
let bucket_key = client_ip.clone().unwrap_or_else(|| {
req.key
.chars()
.take(24)
.collect::<String>()
});
if !crate::rate_limit::consume(
&state.db,
"validate_ip",
&bucket_key,
/* capacity */ 60.0,
/* refill_per_second */ 1.0,
)
.await?
{
return Ok(Json(reject("rate_limited")));
}
// Step 1: parse & verify signature offline-style, using the server's own
// verifying key (same key the SDK will ship).
let (payload, signature, signed_bytes) = match crypto::parse_key(&req.key) {
Ok(ok) => ok,
Err(e) => {
repo::log_validation(
&state.db,
None,
None,
req.fingerprint.as_deref(),
"bad_format",
client_ip.as_deref(),
user_agent.as_deref(),
None,
Some(&e.to_string()),
)
.await
.ok();
tracing::debug!(error = %e, "rejected malformed key");
return Ok(Json(reject("bad_format")));
}
};
if crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature).is_err() {
repo::log_validation(
&state.db,
Some(&payload.license_id.to_string()),
Some(&payload.product_id.to_string()),
req.fingerprint.as_deref(),
"bad_signature",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("bad_signature")));
}
let license_id = payload.license_id.to_string();
let product_id = payload.product_id.to_string();
// Step 2: look up the license row.
let license = match repo::get_license_by_id(&state.db, &license_id).await? {
Some(l) => l,
None => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"not_found",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("not_found")));
}
};
// Step 3: status checks — authoritative server-side.
match license.status.as_str() {
"active" => {}
"revoked" => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"revoked",
client_ip.as_deref(),
user_agent.as_deref(),
None,
license.revocation_reason.as_deref(),
)
.await
.ok();
return Ok(Json(reject("revoked")));
}
"suspended" => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"suspended",
client_ip.as_deref(),
user_agent.as_deref(),
None,
license.suspension_reason.as_deref(),
)
.await
.ok();
return Ok(Json(reject("suspended")));
}
other => {
tracing::warn!(status = other, license_id, "unknown license status");
return Ok(Json(reject("invalid_state")));
}
}
// Step 4: product match (optional).
let product = repo::get_product_by_id(&state.db, &license.product_id).await?;
if let (Some(expected_slug), Some(p)) = (&req.product_slug, &product) {
if &p.slug != expected_slug {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"product_mismatch",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("product_mismatch")));
}
}
// Step 5: expiry + grace.
let now = Utc::now();
let mut in_grace_period = false;
let mut grace_until: Option<String> = None;
if let Some(exp_str) = &license.expires_at {
if let Ok(exp_dt) = DateTime::parse_from_rfc3339(exp_str) {
let exp_utc = exp_dt.with_timezone(&Utc);
let grace_cutoff = exp_utc + chrono::Duration::seconds(license.grace_seconds);
if now >= grace_cutoff {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"expired",
client_ip.as_deref(),
user_agent.as_deref(),
None,
Some(&format!("expired at {exp_str}")),
)
.await
.ok();
return Ok(Json(ValidateResp {
ok: false,
reason: Some("expired".into()),
license_id: Some(license_id),
product_id: Some(product_id),
expires_at: Some(exp_str.clone()),
..Default::default()
}));
} else if now >= exp_utc {
in_grace_period = true;
grace_until = Some(grace_cutoff.to_rfc3339());
}
}
}
// Step 6: fingerprint + machine binding.
// - Single-seat (max_machines == 1): preserve legacy column-based TOFU
// on `licenses.fingerprint` for backwards compatibility, AND also
// write/update a `machines` row so admins see a consistent view.
// - Multi-seat: look up / auto-activate in the machines table, enforce
// the cap.
let mut machine_id: Option<String> = None;
if let Some(fp) = req.fingerprint.as_deref() {
let fp_hash = crate::hex_sha256(fp);
if license.max_machines == 1 {
match &license.fingerprint {
Some(stored) if stored != fp => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
Some(fp),
"fingerprint_mismatch",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("fingerprint_mismatch")));
}
Some(_) => {
// Already bound and matches — touch heartbeat on any machine row.
if let Some(m) =
repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?
{
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
machine_id = Some(m.id);
}
}
None => {
repo::bind_fingerprint_if_unset(&state.db, &license_id, fp).await?;
let m = repo::activate_machine(
&state.db,
&license_id,
fp,
&fp_hash,
req.hostname.as_deref(),
req.platform.as_deref(),
client_ip.as_deref(),
)
.await?;
crate::webhooks::dispatch(
&state,
"machine.activated",
&serde_json::json!({
"license_id": license_id,
"machine_id": m.id,
"fingerprint_hash": fp_hash,
}),
)
.await;
machine_id = Some(m.id);
}
}
} else {
// Multi-seat: consult machines table.
match repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await? {
Some(m) => {
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
machine_id = Some(m.id);
}
None => {
// Count existing active machines. max_machines = 0 means unlimited.
let active = repo::list_active_machines(&state.db, &license_id).await?;
if license.max_machines > 0 && active.len() as i64 >= license.max_machines {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
Some(fp),
"too_many_machines",
client_ip.as_deref(),
user_agent.as_deref(),
None,
Some(&format!(
"cap {} already reached",
license.max_machines
)),
)
.await
.ok();
return Ok(Json(ValidateResp {
ok: false,
reason: Some("too_many_machines".into()),
license_id: Some(license_id),
product_id: Some(product_id),
max_machines: Some(license.max_machines),
..Default::default()
}));
}
let m = repo::activate_machine(
&state.db,
&license_id,
fp,
&fp_hash,
req.hostname.as_deref(),
req.platform.as_deref(),
client_ip.as_deref(),
)
.await?;
crate::webhooks::dispatch(
&state,
"machine.activated",
&serde_json::json!({
"license_id": license_id,
"machine_id": m.id,
"fingerprint_hash": fp_hash,
}),
)
.await;
machine_id = Some(m.id);
}
}
}
// If the signed payload is itself fingerprint-bound, enforce hash
// match against the signed blob (an extra belt-and-braces check).
if payload.is_fingerprint_bound() && payload.fingerprint_hash != hash_fingerprint(fp) {
return Ok(Json(reject("fingerprint_mismatch")));
}
}
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"ok",
client_ip.as_deref(),
user_agent.as_deref(),
machine_id.as_deref(),
if in_grace_period {
Some("in_grace_period")
} else {
None
},
)
.await
.ok();
Ok(Json(ValidateResp {
ok: true,
reason: None,
license_id: Some(license_id),
product_id: Some(product_id),
product_slug: product.map(|p| p.slug),
issued_at: Some(license.issued_at),
expires_at: license.expires_at,
grace_until,
in_grace_period: if in_grace_period { Some(true) } else { None },
is_trial: if license.is_trial { Some(true) } else { None },
entitlements: license.entitlements,
status: Some(license.status),
machine_id,
max_machines: Some(license.max_machines),
}))
}
+305
View File
@@ -0,0 +1,305 @@
//! Payment-provider webhook landing endpoint.
//!
//! Generic over the active `PaymentProvider` (BTCPay today; Zaprite in
//! v0.3). The flow:
//!
//! 1. The provider POSTs an invoice status event here. We hand the raw
//! bytes + headers to the active provider's `validate_webhook` so it
//! can apply its own signature scheme before we trust the body.
//! 2. On `InvoiceSettled`, we mark the invoice settled AND issue a
//! license row (if one doesn't already exist for this invoice —
//! webhooks can be retried). Idempotency is critical.
//! 3. On other events (expired / invalid / refunded), we update status
//! and (for refunds in v0.3) revoke the license.
//!
//! We do **not** sign and return the license key here — the key is
//! lazily re-derived from the stored license row when the buyer polls
//! `/v1/purchase/:invoice_id`. This keeps webhook handling fast and
//! means a dropped webhook response doesn't lose a key.
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use crate::payment::ProviderWebhookEvent;
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
};
use chrono::Utc;
pub async fn handle(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<StatusCode> {
// Active provider validates its own webhooks (each provider has a
// different signature scheme — BTCPay's HMAC-SHA256 in BTCPay-Sig,
// Zaprite's TBD). On any verification failure we 401.
let provider = state.payment_provider().await?;
let event = provider
.validate_webhook(&headers, &body)
.map_err(|e| AppError::Unauthorized.tap_log(format!("webhook validation: {e:#}")))?;
let provider_invoice_id = match event.provider_invoice_id() {
Some(id) => id.to_string(),
None => {
tracing::info!("webhook event without an invoice id; acking");
return Ok(StatusCode::OK);
}
};
let new_status = match &event {
ProviderWebhookEvent::InvoiceSettled { .. } => Some("settled"),
ProviderWebhookEvent::InvoiceExpired { .. } => Some("expired"),
ProviderWebhookEvent::InvoiceInvalid { .. } => Some("invalid"),
// Refunds are a v0.3 surface; for now we treat them as a noop
// and just ack so the provider stops retrying. Once the
// license-revoke-on-refund flow ships, this branch flips to
// doing the revoke + audit-entry work.
ProviderWebhookEvent::InvoiceRefunded { .. } => {
tracing::info!(
provider_invoice_id = %provider_invoice_id,
"refund webhook received; revoke-on-refund flow lands in v0.3"
);
return Ok(StatusCode::OK);
}
ProviderWebhookEvent::Other { kind, .. } => {
tracing::info!(
event_type = %kind,
provider_invoice_id = %provider_invoice_id,
"ignoring non-actionable webhook event"
);
return Ok(StatusCode::OK);
}
};
let new_status = match new_status {
Some(s) => s,
None => return Ok(StatusCode::OK),
};
tracing::info!(
provider = provider.kind().as_str(),
new_status,
provider_invoice_id = %provider_invoice_id,
"webhook event applied"
);
// Persist status.
repo::update_invoice_status(&state.db, &provider_invoice_id, new_status).await?;
// If the invoice is going to a non-success terminal state, free any
// discount-code slot that was reserved for it. We need the internal
// invoice id (not the provider one) to look up the redemption.
if matches!(new_status, "expired" | "invalid") {
if let Ok(Some(inv)) =
repo::get_invoice_by_btcpay_id(&state.db, &provider_invoice_id).await
{
if let Ok(Some(redemption)) =
repo::get_pending_redemption_by_invoice(&state.db, &inv.id).await
{
if let Err(e) = repo::cancel_redemption(&state.db, &redemption.id).await {
tracing::warn!(
redemption_id = %redemption.id,
error = %e,
"failed to cancel redemption on terminal invoice; counter slot may leak"
);
}
}
}
}
if new_status != "settled" {
return Ok(StatusCode::OK);
}
// Find the invoice and issue a license if not already issued.
let invoice = repo::get_invoice_by_btcpay_id(&state.db, &provider_invoice_id).await?;
let Some(invoice) = invoice else {
tracing::warn!(
provider_invoice_id = %provider_invoice_id,
"settled invoice not found in local DB; ignoring"
);
return Ok(StatusCode::OK);
};
// Idempotency: if a license already exists for this invoice, do nothing.
if repo::get_license_by_invoice(&state.db, &invoice.id)
.await?
.is_some()
{
return Ok(StatusCode::OK);
}
let _license_id = issue_license_for_invoice(&state, &invoice).await?;
Ok(StatusCode::OK)
}
/// Shared issuance path — used by both the webhook handler and the reconcile
/// loop. Pulls the invoice's associated policy (if the product has a default
/// one) and materializes a license row with the right expiry / entitlements.
pub async fn issue_license_for_invoice(
state: &AppState,
invoice: &crate::models::Invoice,
) -> AppResult<String> {
// Pick the "default" policy for the product: the first active policy
// whose slug is "default" if present, else the first active policy, else
// none (perpetual, no entitlements, max_machines=1).
let policies = repo::list_policies_by_product(&state.db, &invoice.product_id, true).await?;
let policy = policies
.iter()
.find(|p| p.slug == "default")
.or_else(|| policies.first())
.cloned();
let now = Utc::now();
let issued_at = now.to_rfc3339();
let duration_seconds = policy.as_ref().map(|p| p.duration_seconds).unwrap_or(0);
let expires_at = if duration_seconds == 0 {
None
} else {
Some((now + chrono::Duration::seconds(duration_seconds)).to_rfc3339())
};
let grace_seconds = policy.as_ref().map(|p| p.grace_seconds).unwrap_or(0);
let max_machines = policy.as_ref().map(|p| p.max_machines).unwrap_or(1);
let is_trial = policy.as_ref().map(|p| p.is_trial).unwrap_or(false);
let entitlements = policy
.as_ref()
.map(|p| p.entitlements.clone())
.unwrap_or_default();
let license_id = uuid::Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&license_id,
&invoice.product_id,
Some(&invoice.id),
&issued_at,
&serde_json::json!({
"source": "purchase",
"btcpay_invoice_id": invoice.btcpay_invoice_id,
}),
policy.as_ref().map(|p| p.id.as_str()),
expires_at.as_deref(),
grace_seconds,
max_machines,
&entitlements,
is_trial,
invoice.buyer_email.as_deref(),
None,
)
.await?;
tracing::info!(
license_id = %license_id,
invoice_id = %invoice.id,
policy_id = ?policy.as_ref().map(|p| &p.id),
"license issued for settled invoice"
);
// Fire-and-forget Lightning tip to the policy's configured recipient,
// if any. This never blocks issuance: errors are logged + audited inside
// the spawned task. Skipped silently when the policy has no tip config.
if let Some(p) = policy.as_ref() {
if p.tip_recipient.is_some() && p.tip_pct_bps > 0 {
crate::tipping::spawn_tip(
state.clone(),
license_id.clone(),
p.clone(),
invoice.amount_sats,
);
}
}
crate::webhooks::dispatch(
state,
"license.issued",
&serde_json::json!({
"license_id": license_id,
"product_id": invoice.product_id,
"invoice_id": invoice.id,
"policy_id": policy.as_ref().map(|p| &p.id),
"is_trial": is_trial,
"expires_at": expires_at,
"entitlements": entitlements,
"source": "purchase",
}),
)
.await;
// If this invoice used a discount code, finalize the redemption row
// (transition pending → redeemed, attach license_id) and fire a
// `code.redeemed` webhook. Done here (rather than in the webhook
// handler) so both the webhook path and the reconciler-recovered
// path produce identical effects.
if let Some(redemption) =
repo::get_pending_redemption_by_invoice(&state.db, &invoice.id).await?
{
if let Err(e) =
repo::mark_redemption_redeemed(&state.db, &redemption.id, &license_id).await
{
tracing::warn!(
redemption_id = %redemption.id,
license_id = %license_id,
error = %e,
"failed to mark redemption as redeemed; continuing"
);
}
let code_payload = match repo::get_discount_code_by_id(&state.db, &redemption.code_id).await
{
Ok(Some(code)) => Some(code),
_ => None,
};
let _ = repo::insert_audit(
&state.db,
"system",
None,
"code.redeemed",
Some("discount_code"),
Some(&redemption.code_id),
None,
None,
&serde_json::json!({
"redemption_id": redemption.id,
"invoice_id": invoice.id,
"license_id": license_id,
"discount_applied_sats": redemption.discount_applied_sats,
"base_price_sats": redemption.base_price_sats,
"final_price_sats": redemption.final_price_sats,
}),
)
.await;
crate::webhooks::dispatch(
state,
"code.redeemed",
&serde_json::json!({
"redemption_id": redemption.id,
"code_id": redemption.code_id,
"code": code_payload.as_ref().map(|c| c.code.clone()),
"license_id": license_id,
"product_id": invoice.product_id,
"invoice_id": invoice.id,
"discount_applied_sats": redemption.discount_applied_sats,
"base_price_sats": redemption.base_price_sats,
"final_price_sats": redemption.final_price_sats,
}),
)
.await;
}
Ok(license_id)
}
// Small helper to attach a log line to an error conversion.
trait TapLog {
fn tap_log(self, msg: String) -> Self;
}
impl TapLog for AppError {
fn tap_log(self, msg: String) -> Self {
tracing::warn!("{msg}");
self
}
}
@@ -0,0 +1,154 @@
//! Admin CRUD for webhook endpoints.
//!
//! Operators register one or more URLs that will receive signed JSON
//! notifications of interesting events (`license.issued`, `license.revoked`,
//! `machine.activated`, etc.). Each endpoint has its own HMAC-SHA256 secret;
//! the delivery worker in [`crate::webhooks`] signs bodies with it.
//!
//! The secret is only returned to the operator in plaintext on create — once
//! they've stored it somewhere safe, later reads return the secret masked.
//! (If they lose it, they can rotate by deleting + recreating the endpoint.)
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::AppResult;
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use rand::RngCore;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct CreateEndpointReq {
pub url: String,
/// Event types this endpoint is interested in. Use `["*"]` to receive all
/// events. Examples: `license.issued`, `license.revoked`,
/// `license.suspended`, `machine.activated`, `machine.deactivated`,
/// `invoice.settled`.
#[serde(default = "default_event_types")]
pub event_types: Vec<String>,
#[serde(default)]
pub description: String,
/// Optional explicit secret (hex, 32+ bytes). If omitted, the server
/// generates a fresh 32-byte secret and returns it in the response.
#[serde(default)]
pub secret: Option<String>,
}
fn default_event_types() -> Vec<String> {
vec!["*".to_string()]
}
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateEndpointReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let secret = req.secret.unwrap_or_else(generate_secret);
let ep = repo::create_webhook_endpoint(
&state.db,
&req.url,
&secret,
&req.event_types,
&req.description,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"webhook_endpoint.create",
Some("webhook_endpoint"),
Some(&ep.id),
ip.as_deref(),
ua.as_deref(),
&json!({
"url": ep.url,
"event_types": ep.event_types,
}),
)
.await;
// Return the full endpoint (including the plaintext secret) on create —
// this is the only chance the operator gets to see it.
Ok(Json(json!(ep)))
}
fn generate_secret() -> String {
let mut raw = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw);
hex::encode(raw)
}
#[derive(Debug, Deserialize)]
pub struct ListEndpointsQuery {
#[serde(default)]
pub include_secret: bool,
}
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListEndpointsQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let rows = repo::list_webhook_endpoints(&state.db, q.include_secret).await?;
Ok(Json(json!({ "endpoints": rows })))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_webhook_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"webhook_endpoint.set_active",
Some("webhook_endpoint"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
pub async fn delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::delete_webhook_endpoint(&state.db, &id).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"webhook_endpoint.delete",
Some("webhook_endpoint"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
Ok(Json(json!({ "ok": true })))
}
+368
View File
@@ -0,0 +1,368 @@
//! Minimal BTCPay Greenfield API client — only the endpoints this service
//! actually calls. Add more as needs grow.
use anyhow::{Context, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Clone)]
pub struct BtcpayClient {
http: Client,
base_url: String,
api_key: String,
store_id: String,
}
/// Response subset from `POST /api/v1/stores/{storeId}/invoices`.
#[derive(Debug, Deserialize)]
pub struct CreatedInvoice {
pub id: String,
#[serde(rename = "checkoutLink")]
pub checkout_link: String,
pub status: String,
}
/// Fields we include when creating an invoice. BTCPay accepts many more; we
/// only send what we need.
#[derive(Debug, Serialize)]
struct CreateInvoiceRequest<'a> {
amount: String,
currency: &'a str,
metadata: serde_json::Value,
checkout: CheckoutOptions<'a>,
}
#[derive(Debug, Serialize)]
struct CheckoutOptions<'a> {
#[serde(rename = "redirectURL")]
redirect_url: Option<&'a str>,
#[serde(rename = "redirectAutomatically")]
redirect_automatically: bool,
}
impl BtcpayClient {
pub fn new(base_url: &str, api_key: &str, store_id: &str) -> Self {
Self {
http: Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("reqwest client"),
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
store_id: store_id.to_string(),
}
}
/// Create an invoice priced in satoshis. BTCPay accepts "BTC" currency
/// with decimal amounts; we convert sats → BTC here.
pub async fn create_invoice(
&self,
amount_sats: i64,
metadata: serde_json::Value,
redirect_url: Option<&str>,
) -> Result<CreatedInvoice> {
let url = format!(
"{}/api/v1/stores/{}/invoices",
self.base_url, self.store_id
);
let amount_btc = format!("{:.8}", amount_sats as f64 / 100_000_000.0);
let body = CreateInvoiceRequest {
amount: amount_btc,
currency: "BTC",
metadata,
checkout: CheckoutOptions {
redirect_url,
redirect_automatically: true,
},
};
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("calling BTCPay create-invoice")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay create-invoice returned {status}: {text}"
));
}
let invoice: CreatedInvoice = resp
.json()
.await
.context("parsing BTCPay create-invoice response")?;
Ok(invoice)
}
/// Pay a BOLT11 Lightning invoice from the operator's BTCPay node.
/// Used by the tip-recipient flow. Returns the BTCPay payment record so
/// the caller can extract the payment hash and surface it in the audit
/// log. Errors if the store has no internal LN node or the node refuses
/// the payment (insufficient liquidity, invoice already paid, etc.).
///
/// BTCPay endpoint:
/// POST /api/v1/stores/{storeId}/lightning/BTC/invoices/pay
/// { "BOLT11": "<bolt11>" }
///
/// The BTC path-component is the cryptoCode; on BTCPay-Server it's
/// always "BTC" for the Bitcoin Lightning network.
pub async fn pay_lightning_invoice(&self, bolt11: &str) -> Result<serde_json::Value> {
let url = format!(
"{}/api/v1/stores/{}/lightning/BTC/invoices/pay",
self.base_url, self.store_id
);
let body = json!({ "BOLT11": bolt11 });
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("calling BTCPay pay-lightning-invoice")?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay pay-lightning-invoice returned {status}: {text}"
));
}
let payment: serde_json::Value = resp
.json()
.await
.context("parsing BTCPay pay-lightning-invoice response")?;
Ok(payment)
}
/// Fetch invoice state for reconciliation on startup / admin queries.
/// Not used in the hot path; webhooks are the source of truth.
pub async fn get_invoice(&self, invoice_id: &str) -> Result<serde_json::Value> {
let url = format!(
"{}/api/v1/stores/{}/invoices/{}",
self.base_url, self.store_id, invoice_id
);
let resp = self
.http
.get(&url)
.header("Authorization", format!("token {}", self.api_key))
.send()
.await?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!(
"BTCPay get-invoice returned {}",
resp.status()
));
}
Ok(resp.json().await?)
}
#[allow(dead_code)]
pub fn store_id(&self) -> &str {
&self.store_id
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn api_key(&self) -> &str {
&self.api_key
}
// Helper to quickly construct sample metadata for invoice correlation.
pub fn invoice_metadata(product_id: &str, internal_invoice_id: &str) -> serde_json::Value {
json!({
"orderId": internal_invoice_id,
"productId": product_id,
"source": "keysat",
})
}
}
/// Standalone helpers for the authorize / bootstrap flow. These operate
/// *before* a full `BtcpayClient` exists, since we don't yet know which
/// store the API key is scoped to.
#[derive(Debug, Deserialize)]
pub struct StoreSummary {
pub id: String,
pub name: String,
}
/// List the stores the given API key has access to.
pub async fn list_stores(base_url: &str, api_key: &str) -> Result<Vec<StoreSummary>> {
let url = format!("{}/api/v1/stores", base_url.trim_end_matches('/'));
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.get(&url)
.header("Authorization", format!("token {api_key}"))
.send()
.await
.context("calling BTCPay list-stores")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay list-stores returned {status}: {text}"
));
}
Ok(resp.json::<Vec<StoreSummary>>().await?)
}
#[derive(Debug, Deserialize)]
pub struct CreatedWebhook {
pub id: String,
pub secret: Option<String>,
}
/// Register a webhook on the given store pointing at `callback_url` and
/// subscribing to the three invoice lifecycle events we care about.
pub async fn create_webhook(
base_url: &str,
api_key: &str,
store_id: &str,
callback_url: &str,
secret: &str,
) -> Result<CreatedWebhook> {
let url = format!(
"{}/api/v1/stores/{store_id}/webhooks",
base_url.trim_end_matches('/')
);
let body = json!({
"url": callback_url,
"enabled": true,
"automaticRedelivery": true,
"secret": secret,
"authorizedEvents": {
"everything": false,
"specificEvents": [
"InvoiceSettled",
"InvoiceExpired",
"InvoiceInvalid",
],
},
});
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.post(&url)
.header("Authorization", format!("token {api_key}"))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("calling BTCPay create-webhook")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay create-webhook returned {status}: {text}"
));
}
Ok(resp.json::<CreatedWebhook>().await?)
}
/// Delete a webhook on the given store. Used by the Disconnect flow so
/// that re-authorizing later doesn't leave behind a duplicate webhook
/// pointing at this Keysat install.
pub async fn delete_webhook(
base_url: &str,
api_key: &str,
store_id: &str,
webhook_id: &str,
) -> Result<()> {
let url = format!(
"{}/api/v1/stores/{store_id}/webhooks/{webhook_id}",
base_url.trim_end_matches('/')
);
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.delete(&url)
.header("Authorization", format!("token {api_key}"))
.send()
.await
.context("calling BTCPay delete-webhook")?;
if !resp.status().is_success() && resp.status().as_u16() != 404 {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay delete-webhook returned {status}: {text}"
));
}
// 404 is treated as success — the webhook is already gone.
Ok(())
}
/// Revoke a BTCPay API key. Best-effort — failures are logged by the
/// caller but don't block the local Disconnect from completing.
pub async fn revoke_api_key(base_url: &str, api_key: &str) -> Result<()> {
let url = format!("{}/api/v1/api-keys/current", base_url.trim_end_matches('/'));
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.delete(&url)
.header("Authorization", format!("token {api_key}"))
.send()
.await
.context("calling BTCPay revoke-api-key")?;
if !resp.status().is_success() && resp.status().as_u16() != 404 {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay revoke-api-key returned {status}: {text}"
));
}
Ok(())
}
/// List the payment methods configured on a store. Used by the
/// post-connect "missing wallet" detection. Returns the raw JSON array
/// because the per-method shape varies (onchain vs LN, BTC vs altcoins).
/// Empty array → no payment methods configured.
pub async fn list_payment_methods(
base_url: &str,
api_key: &str,
store_id: &str,
) -> Result<Vec<serde_json::Value>> {
let url = format!(
"{}/api/v1/stores/{store_id}/payment-methods",
base_url.trim_end_matches('/')
);
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.get(&url)
.header("Authorization", format!("token {api_key}"))
.send()
.await
.context("calling BTCPay list-payment-methods")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay list-payment-methods returned {status}: {text}"
));
}
let raw: serde_json::Value = resp.json().await?;
Ok(raw
.as_array()
.cloned()
.unwrap_or_default())
}
+125
View File
@@ -0,0 +1,125 @@
//! Persistent BTCPay connection state.
//!
//! Runtime credentials (API key, store, webhook secret) live in the DB so that
//! the operator can reconfigure BTCPay from the StartOS dashboard without
//! editing env vars or restarting the container.
//!
//! Written on first connect (via the authorize flow) and on explicit
//! reconnects. Read at startup to construct the `BtcpayClient`.
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use sqlx::{Row, SqlitePool};
#[derive(Debug, Clone)]
pub struct BtcpayConfig {
pub base_url: String,
pub api_key: String,
pub store_id: String,
pub webhook_id: Option<String>,
pub webhook_secret: String,
}
/// Load the current BTCPay config. Returns `None` if the operator has not
/// completed the authorize flow yet.
pub async fn load(pool: &SqlitePool) -> Result<Option<BtcpayConfig>> {
let row = sqlx::query(
"SELECT base_url, api_key, store_id, webhook_id, webhook_secret \
FROM btcpay_config WHERE id = 1",
)
.fetch_optional(pool)
.await
.context("loading btcpay_config")?;
Ok(row.map(|r| BtcpayConfig {
base_url: r.get("base_url"),
api_key: r.get("api_key"),
store_id: r.get("store_id"),
webhook_id: r.get("webhook_id"),
webhook_secret: r.get("webhook_secret"),
}))
}
/// Delete the entire BTCPay config row. Used by the Disconnect flow.
/// Subsequent calls to `load` return `None` until the operator
/// re-authorizes.
pub async fn clear(pool: &SqlitePool) -> Result<()> {
sqlx::query("DELETE FROM btcpay_config WHERE id = 1")
.execute(pool)
.await
.context("clearing btcpay_config")?;
Ok(())
}
/// Upsert the full config. Called by the authorize-callback path after the
/// service has fetched/created everything it needs from BTCPay.
pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO btcpay_config \
(id, base_url, api_key, store_id, webhook_id, webhook_secret, connected_at) \
VALUES (1, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
base_url = excluded.base_url, \
api_key = excluded.api_key, \
store_id = excluded.store_id, \
webhook_id = excluded.webhook_id, \
webhook_secret = excluded.webhook_secret, \
connected_at = excluded.connected_at",
)
.bind(&cfg.base_url)
.bind(&cfg.api_key)
.bind(&cfg.store_id)
.bind(cfg.webhook_id.as_deref())
.bind(&cfg.webhook_secret)
.bind(&now)
.execute(pool)
.await
.context("saving btcpay_config")?;
Ok(())
}
/// Record a new in-flight authorize state token. The caller has already
/// generated a cryptographically-random token.
pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO btcpay_authorize_state (state_token, created_at) VALUES (?, ?)",
)
.bind(token)
.bind(&now)
.execute(pool)
.await
.context("recording btcpay authorize state")?;
// Best-effort prune of rows older than 30 minutes.
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
let _ = sqlx::query("DELETE FROM btcpay_authorize_state WHERE created_at < ?")
.bind(&cutoff)
.execute(pool)
.await;
Ok(())
}
/// Validate that `token` was issued recently and has not been consumed.
/// Consumes (deletes) the token on success so a replay fails.
pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
let row = sqlx::query(
"SELECT state_token FROM btcpay_authorize_state \
WHERE state_token = ? AND created_at >= ?",
)
.bind(token)
.bind(&cutoff)
.fetch_optional(pool)
.await?;
if row.is_none() {
return Err(anyhow!("unknown or expired authorize state token"));
}
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
.bind(token)
.execute(pool)
.await?;
Ok(())
}
+11
View File
@@ -0,0 +1,11 @@
//! BTCPay Server integration.
//!
//! - [`client`] creates invoices via the BTCPay Greenfield API.
//! - [`webhook`] verifies and parses incoming webhook calls from BTCPay.
//!
//! BTCPay's Greenfield API is documented at
//! <https://docs.btcpayserver.org/API/Greenfield/v1/>.
pub mod client;
pub mod config;
pub mod webhook;
+93
View File
@@ -0,0 +1,93 @@
//! BTCPay webhook handling.
//!
//! BTCPay signs each webhook body with HMAC-SHA256 using the shared secret
//! we configured, and sends the hex digest in the `BTCPay-Sig` header as
//! `sha256=<hex>`. We verify in constant time before trusting anything.
use anyhow::{anyhow, Result};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
/// Verify the `BTCPay-Sig` header matches the raw request body.
///
/// Returns `Ok(())` on success, `Err` on any mismatch. Callers must pass the
/// raw, unmodified body — any reserialization will break the HMAC.
pub fn verify_signature(secret: &str, header_value: &str, raw_body: &[u8]) -> Result<()> {
let expected_hex = header_value
.strip_prefix("sha256=")
.ok_or_else(|| anyhow!("BTCPay-Sig header missing 'sha256=' prefix"))?;
let expected =
hex::decode(expected_hex).map_err(|_| anyhow!("BTCPay-Sig header is not hex"))?;
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC takes any key size");
mac.update(raw_body);
let computed = mac.finalize().into_bytes();
if bool::from(computed.as_slice().ct_eq(&expected)) {
Ok(())
} else {
Err(anyhow!("BTCPay webhook signature mismatch"))
}
}
/// The subset of webhook payload fields we care about. BTCPay sends many
/// event types; we key off `invoiceId` and `type` / `status`.
#[derive(Debug, serde::Deserialize)]
pub struct WebhookEvent {
#[serde(rename = "type")]
pub event_type: String,
#[serde(rename = "invoiceId")]
pub invoice_id: String,
#[serde(default)]
pub metadata: serde_json::Value,
}
impl WebhookEvent {
/// BTCPay fires event types like `InvoiceSettled`, `InvoiceExpired`,
/// `InvoiceInvalid`, `InvoiceProcessing`. We normalize to our internal
/// status vocabulary.
pub fn to_status(&self) -> Option<&'static str> {
match self.event_type.as_str() {
"InvoiceSettled" | "InvoicePaymentSettled" => Some("settled"),
"InvoiceExpired" => Some("expired"),
"InvoiceInvalid" => Some("invalid"),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verifies_correct_signature() {
let secret = "super-secret";
let body = br#"{"type":"InvoiceSettled","invoiceId":"abc"}"#;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let sig = hex::encode(mac.finalize().into_bytes());
let header = format!("sha256={sig}");
assert!(verify_signature(secret, &header, body).is_ok());
}
#[test]
fn rejects_tampered_body() {
let secret = "super-secret";
let body = br#"{"type":"InvoiceSettled","invoiceId":"abc"}"#;
let tampered = br#"{"type":"InvoiceSettled","invoiceId":"evil"}"#;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let sig = hex::encode(mac.finalize().into_bytes());
let header = format!("sha256={sig}");
assert!(verify_signature(secret, &header, tampered).is_err());
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Runtime configuration.
//!
//! Loaded once at startup from environment variables. A `.env` file is read
//! if present (via `dotenvy`) so local development is frictionless. In
//! production on StartOS, the same variables are set by the service manifest.
use anyhow::{anyhow, Context, Result};
use std::net::SocketAddr;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct Config {
/// Where the HTTP server binds.
pub bind: SocketAddr,
/// Path to the SQLite database file (e.g. `/data/keysat.db` inside a
/// Start9 container; `./data/keysat.db` in dev).
pub db_path: PathBuf,
/// Shared secret required on admin endpoints via `Authorization: Bearer ...`.
/// Generated once by the operator and kept secret.
pub admin_api_key: String,
/// BTCPay Server base URL used for daemon → BTCPay API calls. On
/// StartOS this is the internal-network hostname like
/// `http://btcpayserver.startos:23000`, which is only resolvable from
/// inside other StartOS containers.
pub btcpay_url: String,
/// BTCPay Server base URL used for the OPERATOR'S BROWSER. The
/// authorize flow redirects the operator's browser to BTCPay's
/// consent page; that target must be reachable from the LAN /
/// clearnet, not the internal-network hostname. The wrapper sets
/// this to BTCPay's preferred operator-facing URL — typically
/// mDNS (`https://immense-voyage.local:49347`) since the operator
/// is on the same LAN as the Start9.
pub btcpay_browser_url: Option<String>,
/// BTCPay Server PUBLIC URL used for BUYER-facing redirects.
/// The daemon rewrites checkout URLs returned by BTCPay's API so
/// they point at this URL — random internet buyers can't reach
/// mDNS or LAN URLs, so this needs to be a real clearnet domain
/// like `https://btcpay.your-domain.com`. Falls back to
/// `btcpay_browser_url` if unset (useful for local testing only).
pub btcpay_public_url: Option<String>,
/// Seed BTCPay API key, used only on first boot before the operator has
/// completed the authorize flow. Leave empty in the normal case.
pub btcpay_api_key: Option<String>,
/// Seed BTCPay store id. Same rules as `btcpay_api_key` — empty in the
/// normal case.
pub btcpay_store_id: Option<String>,
/// Seed webhook secret. Only used when bootstrapping from env vars.
pub btcpay_webhook_secret: Option<String>,
/// Public base URL of *this* Keysat instance, used when constructing
/// invoice redirect / webhook URLs (e.g. `https://license.example.com`).
pub public_base_url: String,
/// Optional human-readable operator name shown in `/` index responses.
pub operator_name: Option<String>,
}
impl Config {
pub fn from_env() -> Result<Self> {
// Best-effort load of .env in dev. Missing file is not an error.
let _ = dotenvy::dotenv();
// All runtime knobs live under `KEYSAT_*`. For older installs and
// dev shells that predate the rename we still honour the original
// `LICENSING_*` names as a silent fallback.
let bind_str = env_with_fallback("KEYSAT_BIND", "LICENSING_BIND")
.unwrap_or_else(|| "0.0.0.0:8080".to_string());
let bind: SocketAddr = bind_str
.parse()
.with_context(|| format!("KEYSAT_BIND is not a valid socket address: {bind_str}"))?;
let db_path = PathBuf::from(
env_with_fallback("KEYSAT_DB_PATH", "LICENSING_DB_PATH")
.unwrap_or_else(|| "./data/keysat.db".into()),
);
let admin_api_key = required_with_fallback("KEYSAT_ADMIN_API_KEY", "LICENSING_ADMIN_API_KEY")?;
if admin_api_key.len() < 32 {
return Err(anyhow!(
"KEYSAT_ADMIN_API_KEY must be at least 32 characters (use `openssl rand -hex 32`)"
));
}
let btcpay_url = required("BTCPAY_URL")?;
let btcpay_browser_url = optional_nonempty("BTCPAY_BROWSER_URL")
.map(|s| s.trim_end_matches('/').to_string());
let btcpay_public_url = optional_nonempty("BTCPAY_PUBLIC_URL")
.map(|s| s.trim_end_matches('/').to_string())
// Fallback: if no public URL is plumbed, use browser URL.
// Won't work for real customers but is fine for local testing.
.or_else(|| btcpay_browser_url.clone());
let btcpay_api_key = optional_nonempty("BTCPAY_API_KEY");
let btcpay_store_id = optional_nonempty("BTCPAY_STORE_ID");
let btcpay_webhook_secret = optional_nonempty("BTCPAY_WEBHOOK_SECRET");
let public_base_url = required_with_fallback("KEYSAT_PUBLIC_URL", "LICENSING_PUBLIC_URL")?;
let operator_name = env_with_fallback("KEYSAT_OPERATOR_NAME", "LICENSING_OPERATOR_NAME");
Ok(Self {
bind,
db_path,
admin_api_key,
btcpay_url: btcpay_url.trim_end_matches('/').to_string(),
btcpay_browser_url,
btcpay_public_url,
btcpay_api_key,
btcpay_store_id,
btcpay_webhook_secret,
public_base_url: public_base_url.trim_end_matches('/').to_string(),
operator_name,
})
}
}
fn optional_nonempty(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
fn required(name: &str) -> Result<String> {
std::env::var(name).map_err(|_| anyhow!("missing required env var: {name}"))
}
/// Look up a var under its current (KEYSAT_*) name, falling back to the
/// pre-rename (LICENSING_*) name if unset.
fn env_with_fallback(primary: &str, fallback: &str) -> Option<String> {
optional_nonempty(primary).or_else(|| optional_nonempty(fallback))
}
fn required_with_fallback(primary: &str, fallback: &str) -> Result<String> {
env_with_fallback(primary, fallback)
.ok_or_else(|| anyhow!("missing required env var: {primary} (or {fallback})"))
}
+77
View File
@@ -0,0 +1,77 @@
//! Server key lifecycle: generate on first boot, load on subsequent boots.
//!
//! Keys are stored in SQLite (rather than on the filesystem) so the same
//! backup mechanism that protects licenses also protects the signing key.
//! On StartOS, the database file lives under the service's encrypted data
//! volume, so at-rest encryption is handled by the OS.
use anyhow::{Context, Result};
use chrono::Utc;
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::OsRng;
use sqlx::SqlitePool;
/// Both halves of the server keypair.
#[derive(Clone)]
pub struct ServerKeypair {
pub signing: SigningKey,
pub verifying: VerifyingKey,
/// PEM-encoded public key, for display / SDK bundling.
pub public_key_pem: String,
}
/// Load the keypair from the DB, generating and persisting a new one if no
/// row exists. This function is idempotent and safe to call on every boot.
pub async fn load_or_generate(pool: &SqlitePool) -> Result<ServerKeypair> {
// Try to load.
let existing = sqlx::query_as::<_, (String, String)>(
"SELECT public_key_pem, private_key_pem FROM server_keys WHERE id = 1",
)
.fetch_optional(pool)
.await?;
if let Some((pub_pem, priv_pem)) = existing {
let signing = SigningKey::from_pkcs8_pem(&priv_pem)
.context("failed to parse stored private key")?;
let verifying = VerifyingKey::from_public_key_pem(&pub_pem)
.context("failed to parse stored public key")?;
return Ok(ServerKeypair {
signing,
verifying,
public_key_pem: pub_pem,
});
}
// Generate a new keypair.
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
use pkcs8::LineEnding;
let priv_pem = signing
.to_pkcs8_pem(LineEnding::LF)
.context("failed to encode private key to PEM")?
.to_string();
let pub_pem = verifying
.to_public_key_pem(LineEnding::LF)
.context("failed to encode public key to PEM")?;
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO server_keys (id, algorithm, public_key_pem, private_key_pem, created_at)
VALUES (1, 'ed25519', ?, ?, ?)",
)
.bind(&pub_pem)
.bind(&priv_pem)
.bind(&now)
.execute(pool)
.await?;
tracing::info!("generated new Ed25519 server signing key");
Ok(ServerKeypair {
signing,
verifying,
public_key_pem: pub_pem,
})
}
+486
View File
@@ -0,0 +1,486 @@
//! License key cryptography.
//!
//! # Key format
//!
//! A license key presented to users looks like:
//!
//! ```text
//! LIC1-<base32 payload>-<base32 signature>
//! ```
//!
//! The base32 alphabet is `BASE32_NOPAD` (RFC 4648, no padding, case-insensitive
//! decode). Signatures are always 64 bytes of Ed25519.
//!
//! ## Payload — version 1 (legacy, still accepted)
//!
//! A fixed 74-byte blob:
//!
//! | offset | size | field |
//! |--------|------|----------------------------------------------|
//! | 0 | 1 | version = 1 |
//! | 1 | 1 | flags (bit 0: fingerprint-bound) |
//! | 2 | 16 | product_id (UUID, big-endian bytes) |
//! | 18 | 16 | license_id (UUID, big-endian bytes) |
//! | 34 | 8 | issued_at (u64 unix seconds, BE) |
//! | 42 | 32 | fingerprint_hash (SHA-256, zero if unbound) |
//!
//! ## Payload — version 2 (current default)
//!
//! Variable-length. The fixed head is 83 bytes, followed by the entitlements
//! table. Every byte here is signed.
//!
//! | offset | size | field |
//! |--------|------|---------------------------------------------------------|
//! | 0 | 1 | version = 2 |
//! | 1 | 1 | flags |
//! | 2 | 16 | product_id |
//! | 18 | 16 | license_id |
//! | 34 | 8 | issued_at (u64 BE, unix seconds) |
//! | 42 | 8 | expires_at (u64 BE, unix seconds; 0 = perpetual) |
//! | 50 | 32 | fingerprint_hash (SHA-256; zero iff flag bit unset) |
//! | 82 | 1 | entitlements_count (N, 0..=255) |
//! | 83.. | ... | entitlements: N × `<len: u8><ascii bytes>` |
//!
//! Each entitlement is a short ASCII string ≤ 255 bytes; the canonical examples
//! are feature slugs (`"pro"`, `"cloud-sync"`, `"multi-seat"`). The list is
//! signed so offline verifiers can gate features without contacting the server.
//!
//! ## Flag bits (shared across versions)
//!
//! | bit | meaning |
//! |-----|------------------------------------------------------------|
//! | 0 | fingerprint-bound |
//! | 1 | trial license (v2 only; best-effort — clients may warn) |
//!
//! # Why versioned
//!
//! v2 adds expiry and entitlements, both of which need to be inside the signed
//! blob if we want offline enforcement (a stripped entitlement or pushed-back
//! expiry would have to match a valid signature, which the attacker can't
//! produce). Keeping the v1 parser in place means any keys already issued with
//! v1 continue to verify forever — the whole point of cryptographic licensing.
//!
//! # Offline verification
//!
//! Third-party clients ship the server's **public key** (not the private
//! key) bundled in their SDK. They can verify signatures, enforce expiry, and
//! gate features on entitlements entirely offline. Revocation, machine binding,
//! and suspension are authoritative server-side — clients that want true
//! strictness should call `/v1/validate` periodically.
pub mod keys;
use anyhow::{anyhow, Context, Result};
use data_encoding::BASE32_NOPAD;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use sha2::{Digest, Sha256};
use uuid::Uuid;
/// Key format version currently issued by the server.
pub const KEY_VERSION: u8 = 2;
/// v1 format — legacy, still accepted on parse.
pub const KEY_VERSION_V1: u8 = 1;
/// v2 format — current default.
pub const KEY_VERSION_V2: u8 = 2;
/// Fixed-size of the v1 payload (for tests / legacy parsing).
pub const PAYLOAD_V1_LEN: usize = 1 + 1 + 16 + 16 + 8 + 32; // = 74
/// Minimum size of a v2 payload (head only, no entitlements).
pub const PAYLOAD_V2_HEAD_LEN: usize = 1 + 1 + 16 + 16 + 8 + 8 + 32 + 1; // = 83
/// Flag bit indicating the license is bound to a fingerprint hash.
pub const FLAG_FINGERPRINT_BOUND: u8 = 0b0000_0001;
/// Flag bit indicating the license was issued as a trial (comp/paid trial).
/// Clients that care may render a "Trial" badge; enforcement is via expiry.
pub const FLAG_TRIAL: u8 = 0b0000_0010;
/// Prefix that tags our key strings and future-proofs the envelope.
pub const KEY_PREFIX: &str = "LIC1";
/// Parsed, not-yet-verified key payload. This is a unified v1+v2 shape; on a
/// v1 parse we zero-fill the v2-only fields, so downstream code can be
/// version-agnostic as long as it reads `version` before trusting `expires_at`
/// or `entitlements`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LicensePayload {
pub version: u8,
pub flags: u8,
pub product_id: Uuid,
pub license_id: Uuid,
pub issued_at: i64,
/// Unix seconds; `0` means perpetual. Always 0 for v1.
pub expires_at: i64,
/// SHA-256 of the fingerprint, or zeros if `FLAG_FINGERPRINT_BOUND` is unset.
pub fingerprint_hash: [u8; 32],
/// Feature slugs ASCII; empty for v1 or v2 licenses with no entitlements.
pub entitlements: Vec<String>,
}
impl LicensePayload {
pub fn is_fingerprint_bound(&self) -> bool {
self.flags & FLAG_FINGERPRINT_BOUND != 0
}
pub fn is_trial(&self) -> bool {
self.flags & FLAG_TRIAL != 0
}
/// Has this license expired at the given instant? `expires_at == 0` means
/// perpetual and returns `false`.
pub fn is_expired_at(&self, now_unix: i64) -> bool {
self.expires_at != 0 && now_unix >= self.expires_at
}
/// Does this license grant the given entitlement? Comparison is
/// case-sensitive and exact — pick a canonical casing and stick with it.
pub fn has_entitlement(&self, slug: &str) -> bool {
self.entitlements.iter().any(|e| e == slug)
}
/// Serialize to the v2 wire format. Always emits v2 — v1 is parse-only.
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(PAYLOAD_V2_HEAD_LEN + self.entitlements.len() * 16);
buf.push(KEY_VERSION_V2);
buf.push(self.flags);
buf.extend_from_slice(self.product_id.as_bytes());
buf.extend_from_slice(self.license_id.as_bytes());
buf.extend_from_slice(&(self.issued_at as u64).to_be_bytes());
buf.extend_from_slice(&(self.expires_at as u64).to_be_bytes());
buf.extend_from_slice(&self.fingerprint_hash);
// entitlement count — capped at 255 by u8
let n: u8 = self
.entitlements
.len()
.try_into()
.expect("too many entitlements (max 255)");
buf.push(n);
for e in &self.entitlements {
let bytes = e.as_bytes();
let len: u8 = bytes
.len()
.try_into()
.expect("entitlement slug too long (max 255 bytes)");
buf.push(len);
buf.extend_from_slice(bytes);
}
buf
}
/// Parse a payload blob. Dispatches on the first byte (version).
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
if bytes.is_empty() {
return Err(anyhow!("empty payload"));
}
match bytes[0] {
KEY_VERSION_V1 => Self::from_bytes_v1(bytes),
KEY_VERSION_V2 => Self::from_bytes_v2(bytes),
other => Err(anyhow!("unsupported key version: {other}")),
}
}
fn from_bytes_v1(bytes: &[u8]) -> Result<Self> {
if bytes.len() != PAYLOAD_V1_LEN {
return Err(anyhow!(
"v1 payload length {} != expected {}",
bytes.len(),
PAYLOAD_V1_LEN
));
}
let flags = bytes[1];
let product_id = Uuid::from_slice(&bytes[2..18])?;
let license_id = Uuid::from_slice(&bytes[18..34])?;
let issued_at = u64::from_be_bytes(bytes[34..42].try_into().unwrap()) as i64;
let mut fingerprint_hash = [0u8; 32];
fingerprint_hash.copy_from_slice(&bytes[42..74]);
Ok(Self {
version: KEY_VERSION_V1,
flags,
product_id,
license_id,
issued_at,
expires_at: 0,
fingerprint_hash,
entitlements: Vec::new(),
})
}
fn from_bytes_v2(bytes: &[u8]) -> Result<Self> {
if bytes.len() < PAYLOAD_V2_HEAD_LEN {
return Err(anyhow!(
"v2 payload length {} < head length {}",
bytes.len(),
PAYLOAD_V2_HEAD_LEN
));
}
let flags = bytes[1];
let product_id = Uuid::from_slice(&bytes[2..18])?;
let license_id = Uuid::from_slice(&bytes[18..34])?;
let issued_at = u64::from_be_bytes(bytes[34..42].try_into().unwrap()) as i64;
let expires_at = u64::from_be_bytes(bytes[42..50].try_into().unwrap()) as i64;
let mut fingerprint_hash = [0u8; 32];
fingerprint_hash.copy_from_slice(&bytes[50..82]);
let n = bytes[82] as usize;
let mut entitlements = Vec::with_capacity(n);
let mut cursor = PAYLOAD_V2_HEAD_LEN;
for i in 0..n {
if cursor >= bytes.len() {
return Err(anyhow!(
"truncated entitlement list at index {i} (cursor {cursor}, len {})",
bytes.len()
));
}
let len = bytes[cursor] as usize;
cursor += 1;
if cursor + len > bytes.len() {
return Err(anyhow!(
"entitlement {i} length {len} runs past end of payload"
));
}
let slug = std::str::from_utf8(&bytes[cursor..cursor + len])
.with_context(|| format!("entitlement {i} is not UTF-8"))?;
entitlements.push(slug.to_string());
cursor += len;
}
if cursor != bytes.len() {
return Err(anyhow!(
"trailing bytes after entitlement list ({} unread)",
bytes.len() - cursor
));
}
Ok(Self {
version: KEY_VERSION_V2,
flags,
product_id,
license_id,
issued_at,
expires_at,
fingerprint_hash,
entitlements,
})
}
}
/// Hash a raw fingerprint string. We hash so that the full fingerprint never
/// travels inside the key (only its hash), making keys shorter and hiding
/// information like MAC addresses from anyone who intercepts a key string.
pub fn hash_fingerprint(fp: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(fp.as_bytes());
hasher.finalize().into()
}
/// Encode a payload + signature into a user-facing key string.
pub fn encode_key(payload: &LicensePayload, signature: &Signature) -> String {
let payload_b32 = BASE32_NOPAD.encode(&payload.to_bytes());
let sig_b32 = BASE32_NOPAD.encode(&signature.to_bytes());
format!("{KEY_PREFIX}-{payload_b32}-{sig_b32}")
}
/// Parse a user-provided key string into its payload + signature components
/// (plus the raw signed bytes, which the caller needs to verify against).
/// Does *not* verify the signature — call `verify_key` for that.
pub fn parse_key(s: &str) -> Result<(LicensePayload, Signature, Vec<u8>)> {
let s = s.trim();
let mut parts = s.splitn(3, '-');
let prefix = parts.next().context("key is empty")?;
if prefix != KEY_PREFIX {
return Err(anyhow!("unrecognized key prefix: {prefix}"));
}
let payload_b32 = parts.next().context("missing payload section")?;
let sig_b32 = parts.next().context("missing signature section")?;
let payload_bytes = BASE32_NOPAD
.decode(payload_b32.to_ascii_uppercase().as_bytes())
.context("invalid base32 in payload")?;
let sig_bytes = BASE32_NOPAD
.decode(sig_b32.to_ascii_uppercase().as_bytes())
.context("invalid base32 in signature")?;
let payload = LicensePayload::from_bytes(&payload_bytes)?;
let sig_array: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("signature length != 64"))?;
let signature = Signature::from_bytes(&sig_array);
Ok((payload, signature, payload_bytes))
}
/// Sign a payload with the server's private key.
pub fn sign_payload(signing_key: &SigningKey, payload: &LicensePayload) -> Signature {
signing_key.sign(&payload.to_bytes())
}
/// Verify a parsed payload's signature against a public key.
///
/// For v2 keys, `signed_bytes` is the raw payload blob that was parsed from
/// the wire. For v1 keys it's the 74-byte v1 blob. Always pass the blob you
/// got out of `parse_key` directly — never re-serialize a `LicensePayload`,
/// because we always serialize as v2 and that will break v1 signatures.
pub fn verify_payload(
verifying_key: &VerifyingKey,
signed_bytes: &[u8],
signature: &Signature,
) -> Result<()> {
verifying_key
.verify(signed_bytes, signature)
.context("signature verification failed")
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
fn test_payload() -> LicensePayload {
LicensePayload {
version: KEY_VERSION_V2,
flags: 0,
product_id: Uuid::new_v4(),
license_id: Uuid::new_v4(),
issued_at: 1_700_000_000,
expires_at: 0,
fingerprint_hash: [0u8; 32],
entitlements: Vec::new(),
}
}
#[test]
fn roundtrip_unbound_perpetual_v2() {
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
let payload = test_payload();
let sig = sign_payload(&signing, &payload);
let encoded = encode_key(&payload, &sig);
let (parsed, parsed_sig, signed_bytes) = parse_key(&encoded).unwrap();
assert_eq!(parsed, payload);
verify_payload(&verifying, &signed_bytes, &parsed_sig).unwrap();
}
#[test]
fn roundtrip_with_entitlements_and_expiry() {
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
let payload = LicensePayload {
expires_at: 1_900_000_000,
entitlements: vec![
"pro".to_string(),
"cloud-sync".to_string(),
"multi-seat".to_string(),
],
..test_payload()
};
let sig = sign_payload(&signing, &payload);
let encoded = encode_key(&payload, &sig);
let (parsed, parsed_sig, signed_bytes) = parse_key(&encoded).unwrap();
assert_eq!(parsed, payload);
assert!(parsed.has_entitlement("pro"));
assert!(parsed.has_entitlement("cloud-sync"));
assert!(!parsed.has_entitlement("enterprise"));
assert!(!parsed.is_expired_at(1_800_000_000));
assert!(parsed.is_expired_at(1_900_000_000));
verify_payload(&verifying, &signed_bytes, &parsed_sig).unwrap();
}
#[test]
fn tampered_payload_fails_verification() {
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
let payload = LicensePayload {
entitlements: vec!["free".to_string()],
..test_payload()
};
let sig = sign_payload(&signing, &payload);
let encoded = encode_key(&payload, &sig);
let (_, parsed_sig, signed_bytes) = parse_key(&encoded).unwrap();
// Flip a bit in the signed blob.
let mut tampered = signed_bytes.clone();
let last = tampered.len() - 1;
tampered[last] ^= 0x01;
assert!(verify_payload(&verifying, &tampered, &parsed_sig).is_err());
}
#[test]
fn fingerprint_bound_roundtrip() {
let signing = SigningKey::generate(&mut OsRng);
let fp = "machine-abc-123";
let payload = LicensePayload {
flags: FLAG_FINGERPRINT_BOUND,
fingerprint_hash: hash_fingerprint(fp),
..test_payload()
};
let sig = sign_payload(&signing, &payload);
let encoded = encode_key(&payload, &sig);
let (parsed, _, _) = parse_key(&encoded).unwrap();
assert!(parsed.is_fingerprint_bound());
assert_eq!(parsed.fingerprint_hash, hash_fingerprint(fp));
}
#[test]
fn trial_flag_roundtrip() {
let signing = SigningKey::generate(&mut OsRng);
let payload = LicensePayload {
flags: FLAG_TRIAL,
expires_at: 1_710_000_000,
..test_payload()
};
let sig = sign_payload(&signing, &payload);
let encoded = encode_key(&payload, &sig);
let (parsed, _, _) = parse_key(&encoded).unwrap();
assert!(parsed.is_trial());
}
#[test]
fn v1_parse_still_works() {
// Hand-craft a v1-shaped payload (the wire format that old service
// versions emitted) and confirm we still parse it, zero-filling the
// v2-only fields.
let product_id = Uuid::new_v4();
let license_id = Uuid::new_v4();
let mut v1 = Vec::with_capacity(PAYLOAD_V1_LEN);
v1.push(KEY_VERSION_V1);
v1.push(FLAG_FINGERPRINT_BOUND);
v1.extend_from_slice(product_id.as_bytes());
v1.extend_from_slice(license_id.as_bytes());
v1.extend_from_slice(&1_700_000_000u64.to_be_bytes());
v1.extend_from_slice(&hash_fingerprint("rig-1"));
assert_eq!(v1.len(), PAYLOAD_V1_LEN);
let parsed = LicensePayload::from_bytes(&v1).unwrap();
assert_eq!(parsed.version, KEY_VERSION_V1);
assert!(parsed.is_fingerprint_bound());
assert_eq!(parsed.expires_at, 0);
assert!(parsed.entitlements.is_empty());
assert_eq!(parsed.product_id, product_id);
assert_eq!(parsed.license_id, license_id);
}
#[test]
fn truncated_entitlement_list_is_rejected() {
// v2 payload head claiming 2 entitlements but only 1 supplied.
let mut buf = Vec::new();
buf.push(KEY_VERSION_V2);
buf.push(0);
buf.extend_from_slice(&[0u8; 16]);
buf.extend_from_slice(&[0u8; 16]);
buf.extend_from_slice(&0u64.to_be_bytes()); // issued_at
buf.extend_from_slice(&0u64.to_be_bytes()); // expires_at
buf.extend_from_slice(&[0u8; 32]); // fingerprint
buf.push(2); // count = 2
buf.push(3); // len = 3
buf.extend_from_slice(b"pro");
// missing the second entitlement entirely
assert!(LicensePayload::from_bytes(&buf).is_err());
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Database layer. Runs migrations on startup and provides typed repository
//! helpers for each table. Using `sqlx::query` (not `query!`) keeps the
//! project buildable without a live DB at compile time.
pub mod repo;
use anyhow::{Context, Result};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::SqlitePool;
use std::path::Path;
use std::str::FromStr;
/// Opens (or creates) the SQLite database at `path`, applies migrations, and
/// returns a connection pool ready for use.
pub async fn init(path: &Path) -> Result<SqlitePool> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating parent dir for db at {}", path.display()))?;
}
}
let url = format!("sqlite://{}", path.display());
let opts = SqliteConnectOptions::from_str(&url)?
.create_if_missing(true)
// WAL mode is the right default for a read-heavy validation workload.
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.foreign_keys(true)
.busy_timeout(std::time::Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(opts)
.await
.with_context(|| format!("opening sqlite at {}", path.display()))?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.context("running migrations")?;
tracing::info!(path = %path.display(), "database ready");
Ok(pool)
}
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
//! Unified error type for the service. Converts into appropriate HTTP
//! responses so handlers can just `?`-propagate.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("conflict: {0}")]
Conflict(String),
#[error("license invalid: {0}")]
LicenseInvalid(String),
#[error("upstream error: {0}")]
Upstream(String),
#[error("BTCPay not configured: connect via the StartOS dashboard first")]
BtcpayNotConfigured,
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("internal error: {0}")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = match &self {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
AppError::LicenseInvalid(_) => (StatusCode::OK, "invalid"),
AppError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream_error"),
AppError::BtcpayNotConfigured => (StatusCode::SERVICE_UNAVAILABLE, "btcpay_not_configured"),
AppError::Database(_) | AppError::Internal(_) => {
tracing::error!(error = %self, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
}
};
let body = Json(json!({
"ok": false,
"error": code,
"message": self.to_string(),
}));
(status, body).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;
+256
View File
@@ -0,0 +1,256 @@
//! Keysat-licenses-Keysat: dogfooded self-licensing layer.
//!
//! The Keysat package ships with the master public key embedded in
//! `TRUST_ROOT_PUBKEY_PEM` below. On every boot we look for a license
//! at `SELF_LICENSE_PATH` (or the `KEYSAT_LICENSE` env var), parse it
//! using the same wire-format machinery the daemon uses to issue
//! customer licenses, and verify its signature against the master
//! public key.
//!
//! Two modes:
//! - `Permissive` (default for dev builds): missing or invalid
//! licenses log a warning and the daemon starts in
//! `Tier::Unlicensed`. No features are gated yet — that's a
//! future v0.2.x flip.
//! - `Enforce`: missing or invalid licenses cause the daemon to
//! refuse to start. Set at compile time via the
//! `KEYSAT_LICENSE_ENFORCE=1` env var. Marketplace builds set
//! this; local dev builds don't.
//!
//! The master pubkey is the *public* half of an Ed25519 keypair held
//! offline by the keysat.xyz team. It is not secret — embedding it in
//! source on GitHub is fine. Anyone with the *private* half can mint
//! Keysat self-licenses; the private half lives on paper backup +
//! hardware-token storage and never touches a connected machine
//! except briefly when a master Keysat instance is being initialized.
use crate::crypto::{parse_key, verify_payload};
use anyhow::{bail, Context, Result};
use ed25519_dalek::pkcs8::DecodePublicKey;
use ed25519_dalek::VerifyingKey;
use std::time::{SystemTime, UNIX_EPOCH};
/// Master public key for Keysat self-licensing. PEM-encoded Ed25519,
/// SubjectPublicKeyInfo wrapped (the format `openssl pkey -pubout`
/// emits). To rotate this in a future release: replace the const,
/// ship a new build, distribute fresh licenses to existing customers.
/// Existing customers' licenses won't verify against the new key —
/// that's the breaking event. Plan rotations carefully.
pub const TRUST_ROOT_PUBKEY_PEM: &str = "-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA=
-----END PUBLIC KEY-----";
/// Where the daemon expects a self-license file. Single line, the raw
/// license-key string in `LIC1-…-…` format. Mounted from the
/// persistent data volume so it survives package upgrades.
pub const SELF_LICENSE_PATH: &str = "/data/keysat-license.txt";
/// Build-time enforcement toggle. `KEYSAT_LICENSE_ENFORCE=1` at
/// `cargo build` time enables enforce mode.
const ENFORCE_FLAG: Option<&str> = option_env!("KEYSAT_LICENSE_ENFORCE");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Missing/invalid license logs a warning and continues. Default.
Permissive,
/// Missing/invalid license refuses to start the daemon.
Enforce,
}
pub fn mode() -> Mode {
match ENFORCE_FLAG {
Some("1") | Some("true") | Some("yes") => Mode::Enforce,
_ => Mode::Permissive,
}
}
#[derive(Debug, Clone)]
pub enum Tier {
/// No license configured, or license verify failed in permissive mode.
Unlicensed { reason: String },
/// Valid license verified against the trust-root.
Licensed {
license_id: uuid::Uuid,
product_id: uuid::Uuid,
/// Unix seconds; 0 means perpetual.
expires_at: i64,
entitlements: Vec<String>,
},
}
impl Tier {
pub fn as_str(&self) -> &'static str {
match self {
Tier::Unlicensed { .. } => "unlicensed",
Tier::Licensed { .. } => "licensed",
}
}
}
/// Boot-time check. In permissive mode this always returns `Ok`; in
/// enforce mode it returns `Err` on missing / invalid / expired
/// licenses, which causes `main` to bail out before we open any
/// network sockets.
pub fn check_at_boot() -> Result<Tier> {
let mode = mode();
tracing::info!(
mode = mode.as_str(),
"Keysat self-license check (mode={})",
mode.as_str()
);
let license_str = match read_license_string() {
Some(s) => s,
None => {
let reason = format!(
"no license at {} or KEYSAT_LICENSE env var",
SELF_LICENSE_PATH
);
return handle_missing_or_invalid(mode, reason, None);
}
};
match verify_license(&license_str) {
Ok(tier) => {
log_licensed(&tier);
Ok(tier)
}
Err(e) => {
let reason = format!("verification failed: {e:#}");
handle_missing_or_invalid(mode, reason, Some(e))
}
}
}
fn handle_missing_or_invalid(
mode: Mode,
reason: String,
err: Option<anyhow::Error>,
) -> Result<Tier> {
match mode {
Mode::Permissive => {
tracing::warn!(
tier = "unlicensed",
"Keysat self-license: {} — running unlicensed (permissive build)",
reason
);
Ok(Tier::Unlicensed { reason })
}
Mode::Enforce => {
tracing::error!(
"Keysat self-license: {} — refusing to start. \
Activate via StartOS → Keysat → Actions → Activate Keysat license.",
reason
);
match err {
Some(e) => Err(e.context("self-license invalid (enforce mode)")),
None => bail!("self-license missing (enforce mode): {reason}"),
}
}
}
}
fn read_license_string() -> Option<String> {
if let Ok(s) = std::env::var("KEYSAT_LICENSE") {
let s = s.trim().to_string();
if !s.is_empty() {
return Some(s);
}
}
let path = std::path::Path::new(SELF_LICENSE_PATH);
if let Ok(s) = std::fs::read_to_string(path) {
let s = s.trim().to_string();
if !s.is_empty() {
return Some(s);
}
}
None
}
/// Verify a license-key string against the embedded trust-root.
/// Returns the parsed `Tier::Licensed` on success.
pub fn verify_license(license_key: &str) -> Result<Tier> {
let trust_key = parse_trust_root_pubkey()?;
let (payload, signature, signed_bytes) =
parse_key(license_key).context("license key parse failed")?;
verify_payload(&trust_key, &signed_bytes, &signature)
.context("license signature does not verify against master pubkey")?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if payload.is_expired_at(now) {
bail!(
"license expired at unix={} (now unix={})",
payload.expires_at,
now
);
}
Ok(Tier::Licensed {
license_id: payload.license_id,
product_id: payload.product_id,
expires_at: payload.expires_at,
entitlements: payload.entitlements,
})
}
/// Persist a verified license string to `SELF_LICENSE_PATH`. Caller
/// is expected to have run `verify_license` first.
pub fn write_license_file(license_key: &str) -> Result<()> {
let path = std::path::Path::new(SELF_LICENSE_PATH);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating parent directory {}", parent.display()))?;
}
std::fs::write(path, format!("{}\n", license_key.trim()))
.with_context(|| format!("writing license to {}", path.display()))?;
Ok(())
}
fn parse_trust_root_pubkey() -> Result<VerifyingKey> {
let pem = TRUST_ROOT_PUBKEY_PEM.trim();
if pem.is_empty() {
bail!("trust-root pubkey not embedded in this build");
}
let vk = VerifyingKey::from_public_key_pem(pem)
.context("trust-root pubkey PEM parse failed")?;
Ok(vk)
}
fn log_licensed(tier: &Tier) {
if let Tier::Licensed {
license_id,
product_id,
expires_at,
entitlements,
} = tier
{
let exp = if *expires_at == 0 {
"perpetual".to_string()
} else {
format!("expires_at_unix={expires_at}")
};
let ents = if entitlements.is_empty() {
"(none)".to_string()
} else {
entitlements.join(",")
};
tracing::info!(
tier = "licensed",
license = %license_id,
product = %product_id,
"Keysat self-license: VERIFIED — {exp}, entitlements={ents}"
);
}
}
impl Mode {
fn as_str(self) -> &'static str {
match self {
Mode::Permissive => "permissive",
Mode::Enforce => "enforce",
}
}
}
+189
View File
@@ -0,0 +1,189 @@
//! Entry point. Wires config → logging → DB → keypair → HTTP server.
mod api;
mod btcpay;
mod config;
mod crypto;
mod db;
mod error;
mod license_self;
mod models;
mod payment;
mod rate_limit;
mod reconcile;
mod tipping;
mod webhooks;
/// Hex-encoded SHA-256 of a string — used everywhere we need a deterministic
/// id from a raw value (machine fingerprints, admin key hashes).
pub fn hex_sha256(s: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(s.as_bytes());
hex::encode(hasher.finalize())
}
use anyhow::Context;
use std::sync::Arc;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// --- logging ---
tracing_subscriber::registry()
.with(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn")),
)
.with(fmt::layer().with_target(false))
.init();
// --- config ---
let cfg = config::Config::from_env().context("loading configuration")?;
tracing::info!(
bind = %cfg.bind,
db = %cfg.db_path.display(),
btcpay_url = %cfg.btcpay_url,
btcpay_browser_url = ?cfg.btcpay_browser_url,
btcpay_public_url = ?cfg.btcpay_public_url,
"starting keysat v{}",
env!("CARGO_PKG_VERSION")
);
// --- self-license tier (Keysat-licenses-Keysat) ---
// Verifies any /data/keysat-license.txt against the embedded master
// pubkey. In permissive builds (default) a missing/invalid license
// logs a warning and we continue. In enforce builds (compiled with
// KEYSAT_LICENSE_ENFORCE=1) a missing/invalid license refuses to
// start. Result is held in app state so the admin UI can surface it.
let self_tier = Arc::new(tokio::sync::RwLock::new(
license_self::check_at_boot()
.context("Keysat self-license check failed (enforce mode)")?,
));
// --- database ---
let pool = db::init(&cfg.db_path).await?;
// --- signing key ---
let keypair = crypto::keys::load_or_generate(&pool).await?;
tracing::info!(
"signing key ready; public key:\n{}",
keypair.public_key_pem.trim()
);
// --- payment provider (may be None until operator connects) ---
let provider: Option<Arc<dyn payment::PaymentProvider>> =
load_btcpay_provider(&pool, &cfg).await.map(|p| {
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
arc
});
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"
),
}
let state = api::AppState {
db: pool,
keypair: Arc::new(keypair),
payment: Arc::new(tokio::sync::RwLock::new(provider)),
config: Arc::new(cfg.clone()),
self_tier,
};
// Spawn background loops before handing state to the router.
reconcile::spawn(state.clone());
webhooks::spawn_delivery_worker(state.clone());
let app = api::router(state).layer(TraceLayer::new_for_http());
// --- serve ---
let listener = tokio::net::TcpListener::bind(cfg.bind)
.await
.with_context(|| format!("binding to {}", cfg.bind))?;
tracing::info!("listening on http://{}", cfg.bind);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
tracing::info!("shutdown complete");
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received");
}
/// Load a BtcpayProvider from (in order): DB, then env var seed, then None.
/// Never fails — an unconfigured service simply returns 503 on purchase paths
/// until the operator completes the connect flow. Returns the concrete
/// `BtcpayProvider` so the caller can decide how to wrap it (we wrap as
/// `Arc<dyn PaymentProvider>` in `main`).
async fn load_btcpay_provider(
pool: &sqlx::SqlitePool,
cfg: &config::Config,
) -> Option<payment::btcpay::BtcpayProvider> {
// DB first.
if let Ok(Some(saved)) = btcpay::config::load(pool).await {
let client = btcpay::client::BtcpayClient::new(
&saved.base_url,
&saved.api_key,
&saved.store_id,
);
return Some(
payment::btcpay::BtcpayProvider::new(client, saved.webhook_secret)
.with_public_base(cfg.btcpay_public_url.clone()),
);
}
// Fall back to env seed (useful for dev / legacy installs).
if let (Some(api_key), Some(store_id), Some(secret)) = (
cfg.btcpay_api_key.as_deref(),
cfg.btcpay_store_id.as_deref(),
cfg.btcpay_webhook_secret.as_deref(),
) {
let client =
btcpay::client::BtcpayClient::new(&cfg.btcpay_url, api_key, store_id);
// Persist the seed into DB so it survives env changes.
let _ = btcpay::config::save(
pool,
&btcpay::config::BtcpayConfig {
base_url: cfg.btcpay_url.clone(),
api_key: api_key.to_string(),
store_id: store_id.to_string(),
webhook_id: None,
webhook_secret: secret.to_string(),
},
)
.await;
return Some(
payment::btcpay::BtcpayProvider::new(client, secret.to_string())
.with_public_base(cfg.btcpay_public_url.clone()),
);
}
None
}
+244
View File
@@ -0,0 +1,244 @@
//! Domain models — shared types used by DB, API, and BTCPay layers.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Product {
pub id: String,
pub slug: String,
pub name: String,
pub description: String,
pub price_sats: i64,
pub active: bool,
/// Arbitrary JSON metadata the developer can attach.
pub metadata: serde_json::Value,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InvoiceStatus {
Pending,
Settled,
Expired,
Invalid,
}
impl InvoiceStatus {
pub fn as_str(&self) -> &'static str {
match self {
InvoiceStatus::Pending => "pending",
InvoiceStatus::Settled => "settled",
InvoiceStatus::Expired => "expired",
InvoiceStatus::Invalid => "invalid",
}
}
pub fn parse(s: &str) -> Self {
match s {
"settled" => InvoiceStatus::Settled,
"expired" => InvoiceStatus::Expired,
"invalid" => InvoiceStatus::Invalid,
_ => InvoiceStatus::Pending,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invoice {
pub id: String,
pub btcpay_invoice_id: String,
pub product_id: String,
pub status: String,
pub buyer_email: Option<String>,
pub buyer_note: Option<String>,
pub amount_sats: i64,
pub checkout_url: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LicenseStatus {
Active,
Revoked,
/// Temporarily disabled but recoverable — distinct from revocation, which
/// is terminal. Suspended licenses fail `/v1/validate` with reason
/// `suspended` until an admin un-suspends them.
Suspended,
}
impl LicenseStatus {
pub fn as_str(&self) -> &'static str {
match self {
LicenseStatus::Active => "active",
LicenseStatus::Revoked => "revoked",
LicenseStatus::Suspended => "suspended",
}
}
}
/// Full license row. Older fields are unchanged; v2 columns live behind
/// `Option`s since they were introduced in migration 0003.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct License {
pub id: String,
pub product_id: String,
pub invoice_id: Option<String>,
pub status: String,
pub fingerprint: Option<String>,
pub bound_identity: Option<String>,
pub issued_at: String,
pub revoked_at: Option<String>,
pub revocation_reason: Option<String>,
pub metadata: serde_json::Value,
// v2 / migration 0003 fields
pub policy_id: Option<String>,
pub expires_at: Option<String>,
pub grace_seconds: i64,
pub max_machines: i64,
pub suspended_at: Option<String>,
pub suspension_reason: Option<String>,
pub entitlements: Vec<String>,
pub is_trial: bool,
pub nostr_npub: Option<String>,
pub buyer_email: Option<String>,
}
/// Reusable license template. A policy says "when we issue a license under
/// this slug, set these defaults" (duration, grace, entitlements, machine
/// cap, trial flag, price override, optional tip recipient).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Policy {
pub id: String,
pub product_id: String,
pub name: String,
pub slug: String,
pub duration_seconds: i64,
pub grace_seconds: i64,
pub max_machines: i64,
pub is_trial: bool,
pub price_sats_override: Option<i64>,
pub entitlements: Vec<String>,
pub metadata: serde_json::Value,
pub active: bool,
/// Lightning Address (user@domain) the daemon tips a percentage of
/// each successful issuance to. None = no tipping. The amount is
/// `license_price_sats * tip_pct_bps / 10000`. Tip failures never
/// block license issuance.
pub tip_recipient: Option<String>,
/// Percentage in basis points (1bps = 0.01%; 100bps = 1%; 10000bps = 100%).
/// 0 = no tipping. Capped at 10000 server-side.
pub tip_pct_bps: i64,
/// Free-form label for the tip recipient — surfaced in the audit log.
pub tip_label: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// A machine activated under a license. One row per active install.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Machine {
pub id: String,
pub license_id: String,
pub fingerprint: String,
pub fingerprint_hash: String,
pub hostname: Option<String>,
pub platform: Option<String>,
pub ip_last_seen: Option<String>,
pub activated_at: String,
pub last_heartbeat_at: Option<String>,
pub deactivated_at: Option<String>,
pub deactivation_reason: Option<String>,
}
impl Machine {
pub fn is_active(&self) -> bool {
self.deactivated_at.is_none()
}
}
/// Outbound webhook subscription.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookEndpoint {
pub id: String,
pub url: String,
/// HMAC-SHA256 secret — never returned on list endpoints after creation.
#[serde(skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,
pub event_types: Vec<String>,
pub active: bool,
pub description: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookDelivery {
pub id: String,
pub endpoint_id: String,
pub event_type: String,
pub payload_json: String,
pub attempt_count: i64,
pub next_attempt_at: Option<String>,
pub last_status_code: Option<i64>,
pub last_error: Option<String>,
pub delivered_at: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: i64,
pub actor_kind: String,
pub actor_hash: Option<String>,
pub action: String,
pub target_kind: Option<String>,
pub target_id: Option<String>,
pub request_ip: Option<String>,
pub user_agent: Option<String>,
pub details: serde_json::Value,
pub occurred_at: String,
}
/// Discount / referral code. See `migrations/0004_discount_codes.sql`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscountCode {
pub id: String,
pub code: String,
/// 'percent' | 'fixed_sats'.
pub kind: String,
/// Basis points if `kind == 'percent'` (0..=10000); sats if `kind == 'fixed_sats'`.
pub amount: i64,
pub max_uses: Option<i64>,
pub used_count: i64,
pub expires_at: Option<String>,
pub applies_to_product_id: Option<String>,
pub applies_to_policy_id: Option<String>,
pub referrer_label: Option<String>,
pub description: String,
pub active: bool,
pub created_at: String,
pub updated_at: String,
}
/// One row per (code, invoice) pair. Status transitions:
/// pending → redeemed (invoice settled, license issued)
/// pending → cancelled (invoice expired or invalidated)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscountRedemption {
pub id: String,
pub code_id: String,
pub invoice_id: String,
pub license_id: Option<String>,
/// 'pending' | 'redeemed' | 'cancelled'.
pub status: String,
pub discount_applied_sats: i64,
pub base_price_sats: i64,
pub final_price_sats: i64,
pub created_at: String,
pub updated_at: String,
}
+250
View File
@@ -0,0 +1,250 @@
//! BTCPay implementation of the [`PaymentProvider`] trait.
//!
//! Wraps the existing `BtcpayClient` (in `crate::btcpay::client`) and
//! the existing webhook signature verifier
//! (`crate::btcpay::webhook::verify_signature`). All BTCPay-specific
//! types and HTTP shape stay in `crate::btcpay::*`; this file is just
//! the trait-shaped facade.
use super::{
CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, PaymentReceipt,
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
};
use crate::btcpay::client::BtcpayClient;
use crate::btcpay::webhook::{verify_signature, WebhookEvent as BtcpayWebhookEvent};
use anyhow::{anyhow, Context, Result};
use axum::http::HeaderMap;
use serde_json::Value;
use std::any::Any;
const BTCPAY_SIG_HEADER: &str = "BTCPay-Sig";
/// Active BTCPay provider. Wraps the lower-level HTTP client and the
/// HMAC secret that BTCPay signs webhooks with. Constructed by
/// `api::btcpay_authorize` after the operator completes the OAuth flow.
///
/// `public_base` is BTCPay's PUBLIC URL (the StartTunnel / clearnet
/// one). Optional because it may not be known yet during very-first-
/// boot. When set, every checkout URL returned by `create_invoice`
/// gets its host rewritten from the internal `.startos` hostname to
/// this public host, so buyers actually receive a URL they can open
/// in their browser.
pub struct BtcpayProvider {
pub(crate) client: BtcpayClient,
pub(crate) webhook_secret: String,
pub(crate) public_base: Option<String>,
}
impl BtcpayProvider {
pub fn new(client: BtcpayClient, webhook_secret: String) -> Self {
Self {
client,
webhook_secret,
public_base: None,
}
}
pub fn with_public_base(mut self, public_base: Option<String>) -> Self {
self.public_base = public_base.filter(|s| !s.trim().is_empty());
self
}
/// Compat accessor for code paths that haven't yet migrated to the
/// `PaymentProvider` trait. Returns the underlying BTCPay-specific
/// client by clone (the client is `Clone` and stores only an HTTP
/// client + a few strings; cloning is cheap).
pub fn client(&self) -> &BtcpayClient {
&self.client
}
pub fn webhook_secret(&self) -> &str {
&self.webhook_secret
}
}
/// Rewrite the host (scheme + host + port) of `url_in` to that of
/// `public_base`, preserving the path, query, and fragment. Used to
/// turn `http://btcpayserver.startos:23000/i/abc?x=y` into
/// `https://btcpay.keysat.xyz/i/abc?x=y` before handing the URL to a
/// buyer's browser. Returns the input unchanged if either URL fails
/// to parse — bad-URL handling stays in the caller.
///
/// `pub(crate)` so other modules (like `api::purchase`) can apply the
/// same rewrite when they go through the compat-shim BtcpayClient
/// path instead of the PaymentProvider trait.
pub(crate) fn rewrite_to_public(url_in: &str, public_base: &str) -> String {
let parsed_in = match url::Url::parse(url_in) {
Ok(u) => u,
Err(_) => return url_in.to_string(),
};
let parsed_pub = match url::Url::parse(public_base) {
Ok(u) => u,
Err(_) => return url_in.to_string(),
};
let mut out = parsed_pub.clone();
out.set_path(parsed_in.path());
out.set_query(parsed_in.query());
out.set_fragment(parsed_in.fragment());
out.to_string()
}
#[async_trait::async_trait]
impl PaymentProvider for BtcpayProvider {
fn kind(&self) -> ProviderKind {
ProviderKind::Btcpay
}
async fn create_invoice(
&self,
params: CreateInvoiceParams<'_>,
) -> Result<CreatedInvoiceHandle> {
// BTCPay invoices in our flow are sat-denominated. If a future
// caller hands us non-sat money for BTCPay, fail loudly — that's
// a programming error, not a runtime condition.
if params.amount.currency != "SAT" {
anyhow::bail!(
"BTCPayProvider.create_invoice expected SAT-denominated amount, got {}",
params.amount.currency
);
}
// The existing BtcpayClient::create_invoice already takes
// (amount_sats, metadata, redirect_url). We pass through.
let metadata = enrich_metadata(params.metadata, params.external_order_id);
let created = self
.client
.create_invoice(params.amount.amount, metadata, Some(params.redirect_url))
.await
.context("BTCPay create-invoice")?;
// Rewrite the checkout URL's host to the public BTCPay URL so
// buyers actually get a link they can open. BTCPay derives the
// checkout URL from whatever URL we used to call its API
// (internal Docker hostname `btcpayserver.startos:23000`) —
// useless to a buyer's browser. If `public_base` is set we
// swap the host; if not, log loudly because that's a misconfig.
let checkout_url = match &self.public_base {
Some(pb) => {
let rewritten = rewrite_to_public(&created.checkout_link, pb);
tracing::info!(
original = %created.checkout_link,
rewritten = %rewritten,
public_base = %pb,
"checkout URL rewritten for buyer-reachability"
);
rewritten
}
None => {
tracing::warn!(
original = %created.checkout_link,
"checkout URL NOT rewritten — public_base is None. \
Set BTCPAY_PUBLIC_URL via the wrapper, or ensure \
BTCPay's interface list includes a clearnet domain. \
Buyer will see the internal Docker hostname which \
is unreachable from outside."
);
created.checkout_link
}
};
Ok(CreatedInvoiceHandle {
provider_invoice_id: created.id,
checkout_url,
})
}
async fn get_invoice_status(
&self,
provider_invoice_id: &str,
) -> Result<ProviderInvoiceStatus> {
let raw = self
.client
.get_invoice(provider_invoice_id)
.await
.context("BTCPay get-invoice")?;
let status = raw
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("Pending");
Ok(match status {
"Settled" | "Complete" => ProviderInvoiceStatus::Settled,
"Expired" => ProviderInvoiceStatus::Expired,
"Invalid" => ProviderInvoiceStatus::Invalid,
// Refunded isn't a top-level BTCPay status; if BTCPay ever
// reports it via metadata we'd handle here. For now it falls
// through to Pending.
_ => ProviderInvoiceStatus::Pending,
})
}
fn validate_webhook(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<ProviderWebhookEvent> {
let sig = headers
.get(BTCPAY_SIG_HEADER)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow!("missing {BTCPAY_SIG_HEADER} header"))?;
verify_signature(&self.webhook_secret, sig, body)
.context("BTCPay webhook signature")?;
let parsed: BtcpayWebhookEvent = serde_json::from_slice(body)
.context("malformed BTCPay webhook body")?;
Ok(match parsed.event_type.as_str() {
"InvoiceSettled" | "InvoicePaymentSettled" => ProviderWebhookEvent::InvoiceSettled {
provider_invoice_id: parsed.invoice_id,
},
"InvoiceExpired" => ProviderWebhookEvent::InvoiceExpired {
provider_invoice_id: parsed.invoice_id,
},
"InvoiceInvalid" => ProviderWebhookEvent::InvoiceInvalid {
provider_invoice_id: parsed.invoice_id,
},
other => ProviderWebhookEvent::Other {
kind: other.to_string(),
provider_invoice_id: Some(parsed.invoice_id),
},
})
}
async fn pay_lightning_invoice(&self, bolt11: &str) -> Result<PaymentReceipt> {
let raw = self
.client
.pay_lightning_invoice(bolt11)
.await
.context("BTCPay pay-lightning-invoice")?;
let payment_hash = raw
.get("paymentHash")
.and_then(|v| v.as_str())
.map(String::from);
Ok(PaymentReceipt { payment_hash, raw })
}
fn as_any(&self) -> &dyn Any {
self
}
}
/// Helper: ensure the provider-side metadata always includes our
/// internal invoice id so webhook events are correlatable. BTCPay
/// preserves arbitrary metadata fields and returns them on
/// `get_invoice` and on webhook deliveries.
fn enrich_metadata(mut metadata: Value, external_order_id: &str) -> Value {
if !metadata.is_object() {
metadata = serde_json::json!({});
}
if let Some(obj) = metadata.as_object_mut() {
// BTCPay's checkout displays `orderId` if present.
obj.entry("orderId")
.or_insert_with(|| Value::String(external_order_id.to_string()));
obj.entry("source")
.or_insert_with(|| Value::String("keysat".to_string()));
}
metadata
}
/// Money helper for callers translating from `i64` sat amounts.
pub fn sats(amount: i64) -> Money {
Money::sats(amount)
}
+215
View File
@@ -0,0 +1,215 @@
//! Payment-provider abstraction.
//!
//! Today there's exactly one provider, BTCPay. v0.3 adds Zaprite. The
//! daemon stores the active provider as a trait object so adding new
//! providers is a single-impl drop-in.
//!
//! ## Why a trait
//!
//! Pre-v0.2 the daemon hard-coded BTCPay assumptions in `webhook.rs`,
//! `purchase.rs`, `reconcile.rs`, and `tipping.rs`. Adding Zaprite would
//! have meant either parallel code paths (gross) or post-hoc retrofitting
//! (worse). The `PaymentProvider` trait is a one-time refactor that lets
//! every later provider slot in cleanly.
//!
//! ## Trait surface
//!
//! Just the operations the rest of the daemon actually needs:
//!
//! - `kind()` — provider identity, for logs / audit / admin UI
//! - `create_invoice` — make a hosted-checkout session, return a URL
//! - `get_invoice_status` — for the reconcile loop (webhook misses)
//! - `validate_webhook` — provider-specific signature scheme + parse
//! - `pay_lightning_invoice` — for the tip-recipient flow; default impl
//! returns a "not supported" error so providers without a Lightning
//! payout capability can stay silent.
//!
//! ## What stays out of the trait
//!
//! Provider-specific setup (OAuth-style consent flows, webhook
//! registration, store enumeration) lives in provider-specific modules
//! like `api::btcpay_authorize`. Those modules are responsible for
//! constructing a provider impl and handing it to
//! `AppState::set_payment_provider`.
use anyhow::Result;
use axum::http::HeaderMap;
use serde::{Deserialize, Serialize};
use std::any::Any;
pub mod btcpay;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProviderKind {
Btcpay,
Zaprite,
}
impl ProviderKind {
pub fn as_str(&self) -> &'static str {
match self {
ProviderKind::Btcpay => "btcpay",
ProviderKind::Zaprite => "zaprite",
}
}
}
/// A monetary amount + the unit it's denominated in.
///
/// We carry currency through the system because v0.3 adds USD/EUR for
/// card payments via Zaprite. v0.2 still emits everything as `SAT`
/// since BTCPay invoices are sat-denominated for our flow.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Money {
/// The currency code. ISO 4217 for fiat; `SAT` and `BTC` for Bitcoin.
pub currency: String,
/// The amount in the currency's smallest indivisible unit (sats for
/// BTC, cents for USD, etc.). Using i64 because integer math is
/// cheaper than decimals and we never need fractional sats.
pub amount: i64,
}
impl Money {
pub fn sats(amount: i64) -> Self {
Money {
currency: "SAT".to_string(),
amount,
}
}
}
/// Inputs for `create_invoice`. Bundled into a struct so the trait
/// signature stays stable as we add fields.
pub struct CreateInvoiceParams<'a> {
pub amount: Money,
/// Where the buyer is sent after a successful payment. The provider
/// appends its own status fragments / query params as needed.
pub redirect_url: &'a str,
/// Arbitrary metadata pinned to the invoice on the provider's side.
/// Used by Keysat to round-trip its internal invoice id back through
/// webhook events (`metadata.orderId` for BTCPay; `externalOrderId`
/// for Zaprite).
pub metadata: serde_json::Value,
/// Keysat's internal invoice id (UUID). Passed back in webhook
/// events to correlate with the local row.
pub external_order_id: &'a str,
/// Buyer email if known. Some providers use this for receipts.
pub buyer_email: Option<&'a str>,
}
/// Result of `create_invoice`. Whatever the provider returned, narrowed
/// to the two things the rest of Keysat actually needs.
#[derive(Debug, Clone)]
pub struct CreatedInvoiceHandle {
/// Provider-side invoice id. BTCPay invoice id today; Zaprite order
/// id later. Stored on the invoice row so we can reconcile.
pub provider_invoice_id: String,
/// Public URL the buyer is redirected to to pay.
pub checkout_url: String,
}
/// Provider-agnostic invoice status used by the reconcile loop. Maps to
/// the daemon's existing `InvoiceStatus` model but stays decoupled so
/// the trait doesn't pull in domain types.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderInvoiceStatus {
Pending,
Settled,
Expired,
Refunded,
Invalid,
}
/// Parsed webhook event. Only the kinds Keysat actually acts on are
/// modeled; everything else falls into `Other` and is ignored.
#[derive(Debug, Clone)]
pub enum ProviderWebhookEvent {
InvoiceSettled {
provider_invoice_id: String,
},
InvoiceExpired {
provider_invoice_id: String,
},
InvoiceInvalid {
provider_invoice_id: String,
},
InvoiceRefunded {
provider_invoice_id: String,
refunded_amount: Option<Money>,
},
/// Anything else the provider sent. We log + 200 it so the provider
/// stops retrying.
Other {
kind: String,
provider_invoice_id: Option<String>,
},
}
impl ProviderWebhookEvent {
pub fn provider_invoice_id(&self) -> Option<&str> {
match self {
ProviderWebhookEvent::InvoiceSettled { provider_invoice_id }
| ProviderWebhookEvent::InvoiceExpired { provider_invoice_id }
| ProviderWebhookEvent::InvoiceInvalid { provider_invoice_id }
| ProviderWebhookEvent::InvoiceRefunded {
provider_invoice_id, ..
} => Some(provider_invoice_id),
ProviderWebhookEvent::Other {
provider_invoice_id,
..
} => provider_invoice_id.as_deref(),
}
}
}
/// Result of paying a Lightning invoice via the provider's LN node.
#[derive(Debug, Clone)]
pub struct PaymentReceipt {
pub payment_hash: Option<String>,
/// Raw provider response, for the audit log.
pub raw: serde_json::Value,
}
/// The trait every payment provider implements.
///
/// Object-safe (uses `&dyn`/`Box<dyn>`) thanks to `#[async_trait]`. The
/// `Any` supertrait lets call sites that still need provider-specific
/// types (e.g., the BTCPay-specific authorize flow) downcast.
#[async_trait::async_trait]
pub trait PaymentProvider: Send + Sync + Any {
fn kind(&self) -> ProviderKind;
async fn create_invoice(
&self,
params: CreateInvoiceParams<'_>,
) -> Result<CreatedInvoiceHandle>;
async fn get_invoice_status(
&self,
provider_invoice_id: &str,
) -> Result<ProviderInvoiceStatus>;
/// Verify and parse a webhook delivery. Implementations are
/// responsible for reading whatever signature header their provider
/// uses, computing the expected HMAC, and constant-time comparing.
fn validate_webhook(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<ProviderWebhookEvent>;
/// Pay a BOLT11 Lightning invoice via the provider's LN node.
/// Default impl returns a "not supported" error so providers
/// without LN payout capability don't have to override.
async fn pay_lightning_invoice(&self, _bolt11: &str) -> Result<PaymentReceipt> {
anyhow::bail!(
"pay_lightning_invoice not supported by this payment provider"
)
}
/// Hatch for compat-era downcasting. Lets `AppState`'s legacy
/// `btcpay_client()` accessor reach the inner BTCPay-specific
/// client. v0.3 will retire the compat accessors and remove this.
fn as_any(&self) -> &dyn Any;
}
+81
View File
@@ -0,0 +1,81 @@
//! Token-bucket rate limiting backed by SQLite.
//!
//! The state for each bucket lives in `rate_buckets` (bucket_kind, bucket_key).
//! Each incoming request refills the bucket based on wall-clock elapsed time
//! since last refill, then tries to spend one token. Returns `true` if the
//! request is allowed, `false` if it's rate-limited.
//!
//! Why store in SQLite instead of in-memory? Because the service is
//! single-tenant and small, and persisting lets us survive restarts without
//! giving attackers a "just bounce the process" bypass. The overhead of one
//! extra SQLite write per hit is negligible at our expected traffic.
use crate::error::AppResult;
use chrono::{DateTime, Utc};
use sqlx::SqlitePool;
/// Try to spend one token from the given bucket. Returns `Ok(true)` if the
/// request is allowed, `Ok(false)` if rate-limited, or `Err` on a DB error.
///
/// - `capacity`: maximum tokens the bucket can hold (and what it starts at)
/// - `refill_per_second`: how many tokens to add per wall-clock second
pub async fn consume(
pool: &SqlitePool,
bucket_kind: &str,
bucket_key: &str,
capacity: f64,
refill_per_second: f64,
) -> AppResult<bool> {
let now = Utc::now();
// Pull existing bucket, if any.
let row = sqlx::query_as::<_, (f64, f64, f64, String)>(
"SELECT tokens_remaining, capacity, refill_per_second, last_refill_at
FROM rate_buckets WHERE bucket_kind = ? AND bucket_key = ?",
)
.bind(bucket_kind)
.bind(bucket_key)
.fetch_optional(pool)
.await?;
let (new_tokens, allowed) = match row {
Some((prev_tokens, _cap, _refill, last_refill_at)) => {
let last = DateTime::parse_from_rfc3339(&last_refill_at)
.map(|t| t.with_timezone(&Utc))
.unwrap_or(now);
let elapsed_s = (now - last).num_milliseconds() as f64 / 1000.0;
let mut tokens = (prev_tokens + elapsed_s * refill_per_second).min(capacity);
if tokens >= 1.0 {
tokens -= 1.0;
(tokens, true)
} else {
(tokens, false)
}
}
None => {
// Start with a full bucket minus the current request.
(capacity - 1.0, true)
}
};
let now_str = now.to_rfc3339();
sqlx::query(
"INSERT INTO rate_buckets
(bucket_kind, bucket_key, tokens_remaining, capacity, refill_per_second, last_refill_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(bucket_kind, bucket_key) DO UPDATE SET
tokens_remaining = excluded.tokens_remaining,
capacity = excluded.capacity,
refill_per_second = excluded.refill_per_second,
last_refill_at = excluded.last_refill_at",
)
.bind(bucket_kind)
.bind(bucket_key)
.bind(new_tokens)
.bind(capacity)
.bind(refill_per_second)
.bind(&now_str)
.execute(pool)
.await?;
Ok(allowed)
}
+146
View File
@@ -0,0 +1,146 @@
//! Invoice reconciliation background task.
//!
//! Webhooks are the primary signal from BTCPay to us — fast, push-based, and
//! authenticated with HMAC. But webhooks can be dropped (network blips, our
//! service restarting during a burst, BTCPay retry-budget exhaustion on a
//! long outage). If we only ever reacted to webhooks, a dropped settle
//! notification would mean a buyer paid and never got their license.
//!
//! Reconciliation closes that gap. Every N seconds we scan our own table
//! for invoices still in `pending` status that were created recently, ask
//! BTCPay directly what their real state is, and reconcile:
//!
//! - BTCPay says `Settled` → mark settled AND issue a license if one
//! doesn't exist yet (idempotency enforced by the UNIQUE index on
//! `licenses.invoice_id`).
//! - BTCPay says `Expired` / `Invalid` → mark accordingly, don't issue.
//! - BTCPay still says `New` / `Processing` → leave it alone.
//!
//! The task is cheap — one DB query and at most N HTTP calls per tick —
//! and bounded (we only look at invoices younger than MAX_AGE_HOURS).
use crate::api::AppState;
use crate::db::repo;
use std::time::Duration;
use tokio::time::sleep;
const TICK: Duration = Duration::from_secs(60);
const MAX_AGE_HOURS: i64 = 72;
pub fn spawn(state: AppState) {
tokio::spawn(async move {
// Small initial delay so we don't race startup logs.
sleep(Duration::from_secs(15)).await;
loop {
if let Err(e) = tick(&state).await {
tracing::warn!(error = %e, "reconciliation tick failed");
}
sleep(TICK).await;
}
});
}
async fn tick(state: &AppState) -> anyhow::Result<()> {
let btcpay = match state.btcpay_client().await {
Ok(c) => c,
Err(_) => return Ok(()), // not configured yet — skip silently
};
let pending = repo::list_pending_invoices(&state.db, MAX_AGE_HOURS)
.await
.map_err(|e| anyhow::anyhow!("listing pending invoices: {e:?}"))?;
if pending.is_empty() {
return Ok(());
}
tracing::debug!(count = pending.len(), "reconciling pending invoices");
for inv in pending {
match btcpay.get_invoice(&inv.btcpay_invoice_id).await {
Ok(raw) => {
let status_str = raw
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let normalized = match status_str.as_str() {
"Settled" | "Complete" => Some("settled"),
"Expired" => Some("expired"),
"Invalid" => Some("invalid"),
// still in flight
_ => None,
};
let Some(new_status) = normalized else { continue };
if new_status == inv.status.as_str() {
continue; // no-op
}
if let Err(e) = repo::update_invoice_status(
&state.db,
&inv.btcpay_invoice_id,
new_status,
)
.await
{
tracing::warn!(
error = %e,
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler failed to update invoice status"
);
continue;
}
// Free any reserved discount-code slot if the invoice
// entered a terminal failure state.
if matches!(new_status, "expired" | "invalid") {
if let Ok(Some(redemption)) =
repo::get_pending_redemption_by_invoice(&state.db, &inv.id).await
{
let _ = repo::cancel_redemption(&state.db, &redemption.id).await;
}
}
if new_status == "settled" {
if let Err(e) = ensure_license(state, &inv).await {
tracing::warn!(
error = %e,
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler failed to issue license after recovered settle"
);
} else {
tracing::info!(
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler issued license for recovered settled invoice"
);
}
}
}
Err(e) => {
tracing::debug!(
error = %e,
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler failed to fetch invoice from BTCPay"
);
}
}
}
Ok(())
}
async fn ensure_license(
state: &AppState,
invoice: &crate::models::Invoice,
) -> anyhow::Result<()> {
if repo::get_license_by_invoice(&state.db, &invoice.id)
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?
.is_some()
{
return Ok(());
}
crate::api::webhook::issue_license_for_invoice(state, invoice)
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
Ok(())
}
+353
View File
@@ -0,0 +1,353 @@
//! Tip-recipient-on-policy: fire a Lightning tip after every successful
//! license issuance under a tip-enabled policy.
//!
//! Flow:
//! 1. License is issued (existing path; this module is called from the
//! reconcile/webhook layer once that completes).
//! 2. Look up the policy. If `tip_recipient` is set and `tip_pct_bps > 0`,
//! compute `amount_sats = paid_sats * tip_pct_bps / 10000`.
//! 3. Resolve the Lightning Address. We support exactly the Lightning
//! Address scheme `user@domain`, which maps to
//! `https://domain/.well-known/lnurlp/user`. Plain LNURL-pay bech32
//! strings are not supported in v0.1; can add later.
//! 4. Fetch the LNURL-pay metadata, verify the amount fits in
//! `[minSendable, maxSendable]`, request a BOLT11 invoice for our
//! amount via the `callback` URL.
//! 5. Pay the BOLT11 via the operator's BTCPay Lightning node.
//! 6. Record success/failure in the `tip_attempts` audit table.
//!
//! Failure semantics: this module **never** propagates errors back to the
//! issuance path. A tip failing is a logged + audited concern, not a reason
//! to fail a customer's purchase. Operators set up tipping voluntarily;
//! they accept the trade-off that an occasional tip will fail and can be
//! retried manually.
use crate::api::AppState;
use crate::db::repo;
use crate::models::Policy;
use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
/// Maximum amount in millisats we'll send via a single tip. Defense in
/// depth — a misconfigured `tip_pct_bps` shouldn't be able to drain the
/// wallet on a single sale.
const MAX_TIP_MSAT: u64 = 5_000_000_000; // 50,000,000 sats; 0.5 BTC
#[derive(Debug, Deserialize)]
struct LnurlPayMetadata {
callback: String,
#[serde(rename = "minSendable")]
min_sendable: u64,
#[serde(rename = "maxSendable")]
max_sendable: u64,
#[serde(default)]
tag: String,
}
#[derive(Debug, Deserialize)]
struct LnurlPayInvoice {
pr: String, // BOLT11
}
/// Spawn a tip in the background. Caller fires this after issuance and
/// returns immediately — the customer's purchase response doesn't wait for
/// the tip to complete.
pub fn spawn_tip(
state: AppState,
license_id: String,
policy: Policy,
paid_sats: i64,
) {
tokio::spawn(async move {
if let Err(e) = run_tip(&state, &license_id, &policy, paid_sats).await {
tracing::warn!(
license = %license_id,
policy = %policy.id,
"tip flow ended with error: {e:#}"
);
// run_tip records its own audit entries; this is just the catch-all log.
}
});
}
async fn run_tip(
state: &AppState,
license_id: &str,
policy: &Policy,
paid_sats: i64,
) -> Result<()> {
let recipient = match &policy.tip_recipient {
Some(r) if !r.trim().is_empty() => r.trim().to_string(),
_ => return Ok(()), // no tip configured; not an error
};
let pct = policy.tip_pct_bps;
if pct <= 0 {
return Ok(());
}
let label = policy.tip_label.clone();
// Compute tip amount. Round down (floor); we never tip more than the
// configured percentage of what the buyer paid.
let tip_sats = paid_sats.saturating_mul(pct) / 10_000;
if tip_sats <= 0 {
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
0,
pct,
label.as_deref(),
"skipped",
Some("tip_sats <= 0 after percentage applied"),
None,
)
.await
.ok();
return Ok(());
}
let tip_msat = (tip_sats as u64).saturating_mul(1000);
if tip_msat > MAX_TIP_MSAT {
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"skipped",
Some(&format!(
"tip exceeds safety cap ({} msat > {} msat)",
tip_msat, MAX_TIP_MSAT
)),
None,
)
.await
.ok();
return Ok(());
}
// Resolve Lightning Address → LNURL-pay metadata.
let metadata = match resolve_lightning_address(&recipient).await {
Ok(m) => m,
Err(e) => {
let detail = format!("address resolution failed: {e:#}");
tracing::warn!(license = %license_id, recipient = %recipient, "{detail}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
};
if tip_msat < metadata.min_sendable || tip_msat > metadata.max_sendable {
let detail = format!(
"tip amount {tip_msat} msat outside recipient bounds [{}, {}]",
metadata.min_sendable, metadata.max_sendable
);
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
// Request a BOLT11 invoice from the recipient for our amount.
let invoice = match request_lnurl_invoice(&metadata.callback, tip_msat).await {
Ok(b) => b,
Err(e) => {
let detail = format!("invoice request failed: {e:#}");
tracing::warn!(license = %license_id, "{detail}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
};
// Pay it via the operator's BTCPay Lightning node.
let btcpay = match state.btcpay_client().await {
Ok(c) => c,
Err(e) => {
let detail = format!("BTCPay client unavailable: {e:?}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
};
match btcpay.pay_lightning_invoice(&invoice).await {
Ok(payment) => {
let payment_hash = payment
.get("paymentHash")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
tracing::info!(
license = %license_id,
recipient = %recipient,
amount_sats = tip_sats,
payment_hash = ?payment_hash,
"tip sent"
);
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"sent",
Some(&format!("paid via BTCPay LN node ({} sats)", tip_sats)),
payment_hash.as_deref(),
)
.await
.ok();
}
Err(e) => {
let detail = format!("BTCPay pay-LN-invoice failed: {e:#}");
tracing::warn!(license = %license_id, "{detail}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
}
}
Ok(())
}
/// Parse `user@domain` and fetch the LNURL-pay metadata document at
/// `https://domain/.well-known/lnurlp/user`. Returns the parsed metadata.
async fn resolve_lightning_address(addr: &str) -> Result<LnurlPayMetadata> {
let (user, domain) = addr
.split_once('@')
.ok_or_else(|| anyhow!("not a Lightning Address (expected user@domain)"))?;
if user.is_empty() || domain.is_empty() {
bail!("Lightning Address has empty user or domain");
}
// Reasonable charset check — LN addresses are user-input-safe alphanum + dash + underscore + dot.
let charset_ok = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.');
if !user.chars().all(charset_ok) || !domain.chars().all(charset_ok) {
bail!("Lightning Address contains disallowed characters");
}
let url = format!("https://{domain}/.well-known/lnurlp/{user}");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("building HTTP client")?;
let resp = client.get(&url).send().await.context("LNURL-pay GET")?;
if !resp.status().is_success() {
bail!("LNURL-pay endpoint returned {}", resp.status());
}
let metadata: LnurlPayMetadata = resp
.json()
.await
.context("parsing LNURL-pay metadata response")?;
if !metadata.tag.is_empty() && metadata.tag != "payRequest" {
bail!(
"expected LNURL-pay metadata tag='payRequest', got '{}'",
metadata.tag
);
}
if !metadata.callback.starts_with("https://") {
bail!(
"LNURL-pay callback must be HTTPS, got: {}",
metadata.callback
);
}
Ok(metadata)
}
/// Hit the recipient's `callback` URL with `?amount=<msat>` and return the
/// resulting BOLT11 invoice string.
async fn request_lnurl_invoice(callback: &str, amount_msat: u64) -> Result<String> {
let sep = if callback.contains('?') { '&' } else { '?' };
let url = format!("{callback}{sep}amount={amount_msat}");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("building HTTP client")?;
let resp = client.get(&url).send().await.context("LNURL-pay invoice GET")?;
if !resp.status().is_success() {
bail!(
"LNURL-pay invoice endpoint returned {}",
resp.status()
);
}
// The response can be either { pr, ... } on success or
// { status: "ERROR", reason: "..." } on failure.
let body: serde_json::Value = resp
.json()
.await
.context("parsing LNURL-pay invoice response")?;
if let Some("ERROR") = body.get("status").and_then(|s| s.as_str()) {
let reason = body
.get("reason")
.and_then(|s| s.as_str())
.unwrap_or("unknown");
bail!("LNURL-pay invoice error: {reason}");
}
let parsed: LnurlPayInvoice = serde_json::from_value(body)
.context("LNURL-pay response missing 'pr' field")?;
Ok(parsed.pr)
}
+221
View File
@@ -0,0 +1,221 @@
//! Outbound webhooks.
//!
//! When interesting things happen (a license is issued, revoked, suspended,
//! a machine activates, an invoice settles), the service can POST a signed
//! JSON payload to one or more URLs configured by the operator.
//!
//! Design:
//!
//! - Each endpoint has its own HMAC-SHA256 secret (32 random bytes, hex).
//! - Each delivery is a row in `webhook_deliveries`. Deliveries that fail are
//! retried with exponential backoff up to 10 attempts.
//! - Deliveries are dispatched by a single background task that polls the
//! table every 5 seconds for rows whose `next_attempt_at` is due.
//! - The signature scheme is the same shape as BTCPay's webhook signing
//! (`sha256=<hex>`), so integrators who've already written BTCPay webhook
//! receivers can adapt their code trivially.
use crate::api::AppState;
use crate::db::repo;
use crate::models::WebhookEndpoint;
use chrono::{Duration as ChronoDuration, Utc};
use hmac::{Hmac, Mac};
use serde_json::Value;
use sha2::Sha256;
use std::time::Duration;
use tokio::time::sleep;
type HmacSha256 = Hmac<Sha256>;
/// Signature header we attach to every outbound delivery. Receivers verify by
/// recomputing `HMAC-SHA256(body, secret)` and comparing in constant time.
pub const SIG_HEADER: &str = "X-Keysat-Signature";
/// Event-type header, mirrors `event_type` in the payload for convenience.
pub const EVENT_HEADER: &str = "X-Keysat-Event";
/// Idempotency key header — the delivery id, stable across retries.
pub const DELIVERY_HEADER: &str = "X-Keysat-Delivery";
/// Fire off a logical event. Persists one `webhook_deliveries` row per
/// active subscribed endpoint; the delivery worker handles the HTTP.
///
/// Infallible from the caller's perspective: any DB error is logged and
/// swallowed so event dispatch never blocks the main mutation.
pub async fn dispatch(state: &AppState, event_type: &str, data: &Value) {
let envelope = serde_json::json!({
"event_type": event_type,
"timestamp": Utc::now().to_rfc3339(),
"data": data,
});
let envelope_json = match serde_json::to_string(&envelope) {
Ok(s) => s,
Err(e) => {
tracing::error!(error = %e, "webhook dispatch: failed to serialize envelope");
return;
}
};
let endpoints = match repo::list_active_webhook_endpoints(&state.db).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = ?e, "webhook dispatch: failed to list endpoints");
return;
}
};
for ep in endpoints {
if !ep_wants(&ep, event_type) {
continue;
}
if let Err(e) = repo::enqueue_delivery(&state.db, &ep.id, event_type, &envelope_json).await
{
tracing::warn!(error = ?e, endpoint = %ep.id, "failed to enqueue delivery");
}
}
}
fn ep_wants(ep: &WebhookEndpoint, event_type: &str) -> bool {
ep.event_types.iter().any(|t| t == "*" || t == event_type)
}
/// Background task: every 5s, pick up to 25 deliveries whose `next_attempt_at`
/// is due, POST them, update the row.
pub fn spawn_delivery_worker(state: AppState) {
tokio::spawn(async move {
// Stagger startup slightly to avoid racing the initial reconcile loop.
sleep(Duration::from_secs(5)).await;
loop {
if let Err(e) = tick(&state).await {
tracing::warn!(error = %e, "webhook delivery tick failed");
}
sleep(Duration::from_secs(5)).await;
}
});
}
async fn tick(state: &AppState) -> anyhow::Result<()> {
let due = repo::list_ready_deliveries(&state.db, 25)
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
if due.is_empty() {
return Ok(());
}
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
for d in due {
// Look up endpoint + secret.
let ep = match repo::get_webhook_endpoint_by_id(&state.db, &d.endpoint_id, true).await {
Ok(Some(ep)) if ep.active => ep,
_ => {
// Endpoint gone or disabled — mark delivery permanently failed.
repo::mark_delivery_failure(
&state.db,
&d.id,
None,
"endpoint deleted or disabled",
None,
)
.await
.ok();
continue;
}
};
let secret = ep.secret.as_deref().unwrap_or("");
// Compute HMAC signature of the raw body.
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(e) => {
repo::mark_delivery_failure(
&state.db,
&d.id,
None,
&format!("bad HMAC key: {e}"),
None,
)
.await
.ok();
continue;
}
};
mac.update(d.payload_json.as_bytes());
let sig_hex = hex::encode(mac.finalize().into_bytes());
let sig_header_val = format!("sha256={sig_hex}");
let req = http
.post(&ep.url)
.header("content-type", "application/json")
.header(SIG_HEADER, &sig_header_val)
.header(EVENT_HEADER, &d.event_type)
.header(DELIVERY_HEADER, &d.id)
.body(d.payload_json.clone());
match req.send().await {
Ok(resp) => {
let status = resp.status().as_u16() as i64;
if resp.status().is_success() {
repo::mark_delivery_success(&state.db, &d.id, status).await.ok();
} else {
let backoff = backoff_for(d.attempt_count + 1);
let next = backoff.map(|bs| (Utc::now() + bs).to_rfc3339());
let body_preview = resp.text().await.unwrap_or_default();
let trimmed: String = body_preview.chars().take(200).collect();
repo::mark_delivery_failure(
&state.db,
&d.id,
Some(status),
&format!("non-2xx response: {trimmed}"),
next.as_deref(),
)
.await
.ok();
}
}
Err(e) => {
let backoff = backoff_for(d.attempt_count + 1);
let next = backoff.map(|bs| (Utc::now() + bs).to_rfc3339());
repo::mark_delivery_failure(
&state.db,
&d.id,
None,
&format!("request error: {e}"),
next.as_deref(),
)
.await
.ok();
}
}
}
Ok(())
}
/// Exponential backoff for delivery retries, capped at 10 attempts. Returns
/// `None` when the max is reached (meaning: do not reschedule).
fn backoff_for(attempts_after: i64) -> Option<ChronoDuration> {
const MAX_ATTEMPTS: i64 = 10;
if attempts_after >= MAX_ATTEMPTS {
return None;
}
// 5s, 10s, 30s, 1m, 5m, 15m, 30m, 1h, 2h, 6h
let minutes = match attempts_after {
1 => 0,
2 => 0,
3 => 0,
4 => 1,
5 => 5,
6 => 15,
7 => 30,
8 => 60,
9 => 120,
_ => 360,
};
let seconds = match attempts_after {
1 => 5,
2 => 10,
3 => 30,
_ => 0,
};
Some(ChronoDuration::seconds(seconds) + ChronoDuration::minutes(minutes))
}