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:
@@ -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,
|
||||
})))
|
||||
}
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
@@ -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 & license)</label>
|
||||
<input type="email" id="email" name="email" placeholder="you@example.com" required>
|
||||
<div class="hint">We’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">— License issued —</div>
|
||||
<h3>You’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">…</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’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> · 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’s currently inactive.</p>
|
||||
<p>If you arrived here from a link the seller shared, double-check that you’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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
})))
|
||||
}
|
||||
@@ -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."
|
||||
})))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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…</h1>
|
||||
<p class="lede" id="page-lede">Your Bitcoin payment was received. We’re waiting for it to settle on the network and for the license to be signed. This usually takes under a minute once the next block confirms.</p>
|
||||
|
||||
<!-- pending state (default): polling for the license -->
|
||||
<div class="pending-card" id="pending-card">
|
||||
<div class="stamp">— Awaiting confirmation —</div>
|
||||
<h2>Hang tight.</h2>
|
||||
<p class="sub">This page will refresh automatically when your license is ready.</p>
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
<div class="status-detail" id="status-detail">checking status…</div>
|
||||
</div>
|
||||
|
||||
<!-- success state: license card -->
|
||||
<div class="license-success hide" id="license-success" role="region" aria-label="License issued">
|
||||
<div class="stamp">— License issued —</div>
|
||||
<h2>You’re licensed.</h2>
|
||||
<p class="sub">Your signed license is below. We’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">…</span>
|
||||
<button id="license-key-copy">Copy</button>
|
||||
</div>
|
||||
<div class="save-note">
|
||||
<strong>Save this somewhere safe.</strong> The key is signed at issue time and verifies offline against the seller’s public key. You don’t need to come back here.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- error state: invoice not found, or unrecoverable -->
|
||||
<div class="error-card hide" id="error-card" role="alert">
|
||||
<div id="error-msg">Something went wrong looking up this purchase.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="kfooter">
|
||||
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> · Bitcoin-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 seller’s public key. You can close this tab when you’re done.';
|
||||
}}
|
||||
function showError(msg) {{
|
||||
pendingCard.classList.add('hide');
|
||||
successCard.classList.add('hide');
|
||||
errorMsg.textContent = msg;
|
||||
errorCard.classList.remove('hide');
|
||||
pageTitle.textContent = 'Something went wrong.';
|
||||
pageLede.textContent = 'See the message below for details.';
|
||||
}}
|
||||
|
||||
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 {
|
||||
'&' => "&".to_string(),
|
||||
'<' => "<".to_string(),
|
||||
'>' => ">".to_string(),
|
||||
'"' => """.to_string(),
|
||||
'\'' => "'".to_string(),
|
||||
_ => c.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn pubkey(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"algorithm": "ed25519",
|
||||
"public_key_pem": state.keypair.public_key_pem,
|
||||
}))
|
||||
}
|
||||
@@ -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: 0–10000. 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 })))
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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()),
|
||||
})))
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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})"))
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user