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

Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages,
discount codes, free-license redemption, Apply-discount UX,
self-licensing, and v0.1.0 release notes.
This commit is contained in:
Grant
2026-05-07 10:33:39 -05:00
parent 432250bffc
commit 6ac118ae70
90 changed files with 14896 additions and 524 deletions
+566
View File
@@ -0,0 +1,566 @@
//! Admin endpoints — all require `Authorization: Bearer <admin_api_key>`.
//! The operator uses these to manage products and issue/revoke licenses.
use crate::api::AppState;
use crate::crypto::{encode_key, sign_payload, LicensePayload, KEY_VERSION_V2};
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap},
Json,
};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
/// Guards every admin handler: pulls the bearer token out of the header and
/// compares constant-time against the configured admin key. Returns the
/// SHA-256 hex of the token on success so handlers can write an audit row
/// that identifies *which* credential made the call without logging the raw
/// key.
pub fn require_admin(state: &AppState, headers: &HeaderMap) -> AppResult<String> {
let header_val = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let token = header_val
.strip_prefix("Bearer ")
.ok_or(AppError::Unauthorized)?;
if bool::from(
token
.as_bytes()
.ct_eq(state.config.admin_api_key.as_bytes()),
) {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
Ok(hex::encode(hasher.finalize()))
} else {
Err(AppError::Forbidden)
}
}
/// Pull the best-effort client IP and User-Agent out of the request headers
/// for audit logging.
pub fn request_context(headers: &HeaderMap) -> (Option<String>, Option<String>) {
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string())
.filter(|s| !s.is_empty());
let ua = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
(client_ip, ua)
}
// ---------- Products ----------
#[derive(Debug, Deserialize)]
pub struct CreateProductReq {
pub slug: String,
pub name: String,
#[serde(default)]
pub description: String,
pub price_sats: i64,
#[serde(default)]
pub metadata: Value,
}
pub async fn create_product(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateProductReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if req.price_sats <= 0 {
return Err(AppError::BadRequest("price_sats must be positive".into()));
}
let metadata = if req.metadata.is_null() {
json!({})
} else {
req.metadata
};
let product = repo::create_product(
&state.db,
&req.slug,
&req.name,
&req.description,
req.price_sats,
&metadata,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"product.create",
Some("product"),
Some(&product.id),
ip.as_deref(),
ua.as_deref(),
&json!({ "slug": product.slug, "name": product.name, "price_sats": product.price_sats }),
)
.await;
crate::webhooks::dispatch(
&state,
"product.created",
&json!({ "product": product }),
)
.await;
Ok(Json(json!(product)))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_product_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_product_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"product.set_active",
Some("product"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
// ---------- Licenses ----------
#[derive(Debug, Deserialize)]
pub struct ListLicensesQuery {
pub product_id: String,
}
pub async fn list_licenses(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListLicensesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let licenses = repo::list_licenses_by_product(&state.db, &q.product_id).await?;
Ok(Json(json!({ "licenses": licenses })))
}
#[derive(Debug, Deserialize)]
pub struct SearchLicensesQuery {
pub buyer_email: Option<String>,
pub nostr_npub: Option<String>,
pub invoice_id: Option<String>,
}
/// Free-form lookup used by the "lost key recovery" flow. Searches by email,
/// Nostr npub, or invoice id (whichever is supplied), returns up to 100
/// matching licenses.
pub async fn search_licenses(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<SearchLicensesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let licenses = repo::search_licenses(
&state.db,
q.buyer_email.as_deref(),
q.nostr_npub.as_deref(),
q.invoice_id.as_deref(),
)
.await?;
Ok(Json(json!({ "licenses": licenses })))
}
#[derive(Debug, Deserialize)]
pub struct IssueLicenseReq {
pub product_slug: String,
/// Optional policy slug (within the product). When set, the policy's
/// duration, grace, entitlements, trial flag, and machine cap are used.
#[serde(default)]
pub policy_slug: Option<String>,
/// Optional reason for audit — e.g. "comp", "press", "giveaway".
#[serde(default)]
pub note: Option<String>,
/// Override expiry (ISO-8601 UTC). Ignored if `policy_slug` is set.
#[serde(default)]
pub expires_at: Option<String>,
/// Override entitlements. Ignored if `policy_slug` is set.
#[serde(default)]
pub entitlements: Option<Vec<String>>,
#[serde(default)]
pub max_machines: Option<i64>,
#[serde(default)]
pub grace_seconds: Option<i64>,
#[serde(default)]
pub is_trial: Option<bool>,
#[serde(default)]
pub buyer_email: Option<String>,
#[serde(default)]
pub nostr_npub: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct IssueLicenseResp {
pub license_id: String,
pub product_id: String,
pub license_key: String,
pub issued_at: String,
pub expires_at: Option<String>,
pub entitlements: Vec<String>,
pub is_trial: bool,
pub max_machines: i64,
}
/// Manually issue a license outside the purchase flow. Useful for comps,
/// press keys, grandfathered users, trial keys, or developer testing.
pub async fn issue_license(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<IssueLicenseReq>,
) -> AppResult<Json<IssueLicenseResp>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product_slug)))?;
// Pull the policy (if any) and merge it with per-call overrides.
let policy = if let Some(slug) = &req.policy_slug {
Some(
repo::get_policy_by_slug(&state.db, &product.id, slug)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{slug}' for product '{}'",
req.product_slug
))
})?,
)
} else {
None
};
// Compose effective values: explicit request fields take precedence over
// the policy, which takes precedence over defaults.
let now = Utc::now();
let issued_at = now.to_rfc3339();
let duration_seconds = policy.as_ref().map(|p| p.duration_seconds).unwrap_or(0);
let expires_at = match (req.expires_at.clone(), duration_seconds) {
(Some(explicit), _) => Some(explicit),
(None, 0) => None, // perpetual
(None, secs) => Some((now + Duration::seconds(secs)).to_rfc3339()),
};
let grace_seconds = req
.grace_seconds
.or_else(|| policy.as_ref().map(|p| p.grace_seconds))
.unwrap_or(0);
let max_machines = req
.max_machines
.or_else(|| policy.as_ref().map(|p| p.max_machines))
.unwrap_or(1);
let is_trial = req
.is_trial
.or_else(|| policy.as_ref().map(|p| p.is_trial))
.unwrap_or(false);
let entitlements = req
.entitlements
.clone()
.or_else(|| policy.as_ref().map(|p| p.entitlements.clone()))
.unwrap_or_default();
let license_id = uuid::Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&license_id,
&product.id,
None,
&issued_at,
&json!({
"source": "admin_issue",
"note": req.note,
}),
policy.as_ref().map(|p| p.id.as_str()),
expires_at.as_deref(),
grace_seconds,
max_machines,
&entitlements,
is_trial,
req.buyer_email.as_deref(),
req.nostr_npub.as_deref(),
)
.await?;
// Build v2 signed payload.
let mut flags = 0u8;
if is_trial {
flags |= crate::crypto::FLAG_TRIAL;
}
let payload = LicensePayload {
version: KEY_VERSION_V2,
flags,
product_id: uuid::Uuid::parse_str(&product.id).unwrap(),
license_id: uuid::Uuid::parse_str(&license_id).unwrap(),
issued_at: now.timestamp(),
expires_at: expires_at
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp())
.unwrap_or(0),
fingerprint_hash: [0u8; 32],
entitlements: entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
let license_key = encode_key(&payload, &sig);
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.issue_manual",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({
"product_id": product.id,
"policy_id": policy.as_ref().map(|p| &p.id),
"is_trial": is_trial,
"expires_at": expires_at,
"entitlements": entitlements,
}),
)
.await;
crate::webhooks::dispatch(
&state,
"license.issued",
&json!({
"license_id": license_id,
"product_id": product.id,
"is_trial": is_trial,
"expires_at": expires_at,
"entitlements": entitlements,
"source": "admin_issue",
}),
)
.await;
Ok(Json(IssueLicenseResp {
license_id,
product_id: product.id,
license_key,
issued_at,
expires_at,
entitlements,
is_trial,
max_machines,
}))
}
#[derive(Debug, Deserialize)]
pub struct RevokeReq {
#[serde(default)]
pub reason: String,
}
pub async fn revoke_license(
State(state): State<AppState>,
headers: HeaderMap,
Path(license_id): Path<String>,
Json(req): Json<RevokeReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin revoke".to_string()
} else {
req.reason
};
repo::revoke_license(&state.db, &license_id, &reason).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.revoke",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "reason": reason }),
)
.await;
crate::webhooks::dispatch(
&state,
"license.revoked",
&json!({ "license_id": license_id, "reason": reason }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
// ---------- Suspension / un-suspension ----------
#[derive(Debug, Deserialize)]
pub struct SuspendReq {
#[serde(default)]
pub reason: String,
}
pub async fn suspend_license(
State(state): State<AppState>,
headers: HeaderMap,
Path(license_id): Path<String>,
Json(req): Json<SuspendReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin suspend".to_string()
} else {
req.reason
};
repo::suspend_license(&state.db, &license_id, &reason).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.suspend",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "reason": reason }),
)
.await;
crate::webhooks::dispatch(
&state,
"license.suspended",
&json!({ "license_id": license_id, "reason": reason }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
pub async fn unsuspend_license(
State(state): State<AppState>,
headers: HeaderMap,
Path(license_id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::unsuspend_license(&state.db, &license_id).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.unsuspend",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
crate::webhooks::dispatch(
&state,
"license.unsuspended",
&json!({ "license_id": license_id }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
// ---------- Audit log viewer ----------
#[derive(Debug, Deserialize)]
pub struct ListAuditQuery {
#[serde(default = "default_audit_limit")]
pub limit: i64,
pub action: Option<String>,
}
fn default_audit_limit() -> i64 {
200
}
pub async fn list_audit(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListAuditQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let rows = repo::list_audit(&state.db, q.limit.min(1000).max(1), q.action.as_deref()).await?;
Ok(Json(json!({ "entries": rows })))
}
// ---------- Settings (live-mutable runtime config) ----------
/// Settings key for the operator's public-facing display name. Read by
/// the `/` index handler on every request, so updates take effect
/// immediately — no daemon restart needed.
pub const SETTING_OPERATOR_NAME: &str = "operator_name";
#[derive(Debug, Deserialize)]
pub struct SetOperatorNameReq {
/// New operator name. Empty string clears the setting (reverts to
/// the daemon's startup-time fallback from KEYSAT_OPERATOR_NAME).
pub name: String,
}
pub async fn set_operator_name(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<SetOperatorNameReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let trimmed = req.name.trim();
let stored: Option<&str> = if trimmed.is_empty() { None } else { Some(trimmed) };
repo::settings_set(&state.db, SETTING_OPERATOR_NAME, stored).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"operator_name.set",
Some("setting"),
Some(SETTING_OPERATOR_NAME),
ip.as_deref(),
ua.as_deref(),
&json!({ "value": stored }),
)
.await;
Ok(Json(json!({ "ok": true, "operator_name": stored })))
}
pub async fn get_operator_name(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let stored = repo::settings_get(&state.db, SETTING_OPERATOR_NAME).await?;
let effective = stored
.clone()
.or_else(|| state.config.operator_name.clone());
Ok(Json(json!({
"stored": stored,
"effective": effective,
"fallback_env": state.config.operator_name,
})))
}
+86
View File
@@ -0,0 +1,86 @@
//! Embedded admin web UI.
//!
//! At compile time, every file in `licensing-service/web/` is bundled
//! into the binary via `rust-embed`. At runtime, axum serves them under
//! `/admin/*` — no separate static-file deployment, no nginx, no proxy.
//! The whole admin SPA ships in the same `keysat` executable as the
//! daemon.
//!
//! Auth model: NONE at this HTTP layer. The static assets themselves
//! (HTML, CSS, JS) are public — there's nothing secret in them. The
//! actual gating happens client-side: the index page prompts for the
//! operator's admin API key on first load, validates it against any
//! `/v1/admin/*` endpoint, stores it in localStorage, and uses it as
//! `Authorization: Bearer ...` on every subsequent admin call. The
//! admin-scoped endpoints already enforce the key constant-time, so a
//! random visitor can load `/admin/index.html` but cannot do anything
//! useful without the key.
//!
//! v0.2 first cut: this is scaffolding only. The HTML page contains a
//! login form + a placeholder dashboard. Future SPA work just adds
//! more files into `web/` (or replaces index.html with a built React /
//! Svelte bundle); the serving code below doesn't change.
use axum::{
body::Body,
http::{header, StatusCode, Uri},
response::{IntoResponse, Redirect, Response},
};
use rust_embed::RustEmbed;
/// Compile-time-bundled directory of static admin UI assets. Every file
/// under `web/` (relative to the crate root) is embedded byte-for-byte
/// into the binary.
#[derive(RustEmbed)]
#[folder = "web/"]
struct AdminAssets;
/// `GET /admin` — redirect to `/admin/` so the relative paths in the
/// embedded HTML resolve correctly.
pub async fn admin_root_redirect() -> Redirect {
Redirect::permanent("/admin/")
}
/// `GET /admin/` — serve the SPA shell (index.html).
pub async fn admin_index() -> Response {
serve_embedded("index.html")
}
/// `GET /admin/*path` — serve any other embedded static file. Falls
/// through to `index.html` for unknown paths so client-side routing
/// (e.g. /admin/products, /admin/licenses) works without server-side
/// route registration.
pub async fn admin_asset(uri: Uri) -> Response {
// The Uri here will be the FULL path (including the /admin prefix).
// Strip the prefix to look up the asset.
let path = uri.path();
let stripped = path.strip_prefix("/admin/").unwrap_or(path);
if stripped.is_empty() {
return serve_embedded("index.html");
}
if AdminAssets::get(stripped).is_some() {
serve_embedded(stripped)
} else {
// Unknown path — fall through to index.html so the SPA's
// client-side router can take over. This is the canonical
// fallback pattern for SPAs hosted on path prefixes.
serve_embedded("index.html")
}
}
fn serve_embedded(path: &str) -> Response {
match AdminAssets::get(path) {
Some(file) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.header(header::CONTENT_TYPE, mime.to_string())
// Modest caching — these are versioned with the binary,
// so cache for an hour. A binary upgrade rolls the
// service which evicts the cache anyway.
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(Body::from(file.data.into_owned()))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
None => StatusCode::NOT_FOUND.into_response(),
}
}
@@ -0,0 +1,415 @@
//! BTCPay one-click authorize flow.
//!
//! Instead of making the operator generate an API key by hand and paste it
//! into a form, we use BTCPay's "authorize" redirect flow:
//!
//! 1. Operator clicks "Connect BTCPay" in StartOS — the wrapper action
//! calls `POST /v1/admin/btcpay/connect` (with the admin bearer token)
//! and gets back a BTCPay URL to open in the operator's browser.
//! 2. The operator, already logged into BTCPay on the same box, sees a
//! consent page listing the permissions this service is requesting. They
//! click **Authorize**.
//! 3. BTCPay POSTs back to our `/v1/btcpay/authorize/callback` with the
//! newly-minted API key and the store(s) it was scoped to.
//! 4. We persist the key, pick the target store, register the webhook (with
//! a freshly-generated secret), and save everything in `btcpay_config`.
//! 5. From that moment on, the `BtcpayProvider` (held as an `Arc<dyn
//! PaymentProvider>` in `AppState.payment`) is populated
//! and purchase / webhook endpoints work.
//!
//! If the callback fails for any reason, the operator is shown an error page
//! and can retry. The admin endpoint requires the admin bearer token; the
//! callback path uses the CSRF `state` token to tie a callback back to the
//! issuing operator session.
use crate::api::{admin::require_admin, AppState};
use crate::btcpay::client::{self as btcpay_client, BtcpayClient};
use crate::btcpay::config as btcpay_cfg;
use crate::error::{AppError, AppResult};
use crate::payment::btcpay::BtcpayProvider;
use std::sync::Arc;
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
Form, Json,
};
use data_encoding::BASE32_NOPAD;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// Permissions we request on the authorize page. Each is namespaced by
/// `btcpay.store.*` which means BTCPay will prompt the operator to pick
/// which store(s) to grant.
const REQUESTED_PERMISSIONS: &[&str] = &[
"btcpay.store.canviewstoresettings",
"btcpay.store.canmodifystoresettings", // to register the webhook
"btcpay.store.canviewinvoices",
"btcpay.store.cancreateinvoice",
"btcpay.store.canmodifyinvoices",
];
#[derive(Debug, Serialize)]
pub struct ConnectResp {
/// URL the operator should open in their browser to authorize.
pub authorize_url: String,
/// CSRF state token tied to this round trip.
pub state: String,
}
/// Admin endpoint: starts a connect round trip. Returns the BTCPay authorize
/// URL for the StartOS wrapper action to open in the operator's browser.
pub async fn start_connect(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<ConnectResp>> {
require_admin(&state, &headers)?;
// Idempotency: if BTCPay is already connected, refuse to issue a new
// authorize URL. Re-clicking Connect today produces a duplicate
// webhook subscription on BTCPay, which results in every payment
// event being delivered to Keysat twice. Make the operator go
// through Disconnect first if they really want to re-authorize.
if let Ok(Some(existing)) = btcpay_cfg::load(&state.db).await {
return Err(AppError::Conflict(format!(
"BTCPay is already connected (store {}). Run 'Disconnect BTCPay' first if you need to re-authorize.",
existing.store_id,
)));
}
// Random 20-byte token, base32-encoded, for the CSRF `state` parameter.
let mut raw = [0u8; 20];
rand::thread_rng().fill_bytes(&mut raw);
let state_token = BASE32_NOPAD.encode(&raw);
btcpay_cfg::record_authorize_state(&state.db, &state_token)
.await
.map_err(AppError::Internal)?;
// Construct the authorize URL per BTCPay's docs.
// https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys
//
// CSRF state must travel inside the `redirect` URL itself, NOT as a
// separate query param on the outer authorize URL. Empirical
// observation against BTCPay: arbitrary query params on the
// authorize URL are NOT forwarded to the redirect target. The
// redirect URL is preserved verbatim, so any params we encode INTO
// it survive the round-trip.
let redirect = format!(
"{}/v1/btcpay/authorize/callback?state={}",
state.config.public_base_url,
urlencoding::encode(&state_token),
);
let perm_params = REQUESTED_PERMISSIONS
.iter()
.map(|p| format!("permissions={}", urlencoding::encode(p)))
.collect::<Vec<_>>()
.join("&");
// The authorize URL is followed by the operator's BROWSER, so the host
// must be reachable from outside the container. Use the explicit
// `btcpay_browser_url` if the wrapper provided it; fall back to
// `btcpay_url` only for dev/local setups (where they're the same).
let authorize_base = state
.config
.btcpay_browser_url
.as_deref()
.unwrap_or(&state.config.btcpay_url);
let authorize_url = format!(
"{}/api-keys/authorize?applicationName={}&applicationIdentifier={}&strict=true&selectiveStores=true&redirect={}&{perm_params}",
authorize_base,
urlencoding::encode("Keysat"),
urlencoding::encode("keysat"),
urlencoding::encode(&redirect),
);
Ok(Json(ConnectResp {
authorize_url,
state: state_token,
}))
}
/// Fields BTCPay sends back on the callback. BTCPay POSTs `apiKey`,
/// `userId`, and `permissions[]` as a form body. It also preserves any
/// query-string parameters on the redirect URL — we use that for `state`.
#[derive(Debug, Deserialize)]
pub struct CallbackForm {
#[serde(rename = "apiKey")]
pub api_key: String,
#[serde(rename = "userId")]
pub user_id: Option<String>,
// BTCPay posts `permissions` one-per-occurrence; serde_urlencoded turns
// that into a repeated string. We don't actually need to parse them
// individually — we just re-verify via list_stores.
#[serde(default)]
pub permissions: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct CallbackQuery {
pub state: String,
}
/// The real callback endpoint — POST form-encoded.
pub async fn callback(
State(state): State<AppState>,
Query(q): Query<CallbackQuery>,
Form(form): Form<CallbackForm>,
) -> AppResult<Response> {
finish_connect(&state, &q.state, &form.api_key).await?;
Ok(success_page("BTCPay connected successfully. You can close this tab and return to StartOS."))
}
/// Some BTCPay deployments send the apiKey back as a query string on a GET.
/// Handle that too for robustness.
#[derive(Debug, Deserialize)]
pub struct CallbackGetQuery {
pub state: String,
#[serde(rename = "apiKey")]
pub api_key: Option<String>,
/// Error message if BTCPay declined / operator clicked "Deny".
pub error: Option<String>,
}
pub async fn callback_get(
State(state): State<AppState>,
Query(q): Query<CallbackGetQuery>,
) -> Response {
if let Some(err) = q.error {
return Html(format!(
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
html_escape::encode_text(&err)
))
.into_response();
}
let Some(api_key) = q.api_key else {
// Some installs POST; in that case a bare GET with no apiKey is
// possible if the operator refreshes the tab. Redirect to root.
return Redirect::to("/").into_response();
};
match finish_connect(&state, &q.state, &api_key).await {
Ok(()) => success_page(
"BTCPay connected successfully. You can close this tab and return to StartOS.",
),
Err(e) => Html(format!(
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
html_escape::encode_text(&e.to_string())
))
.into_response(),
}
}
/// Admin endpoint: list payment methods configured on the connected
/// BTCPay store. Proxies to BTCPay's `/api/v1/stores/{id}/payment-methods`.
/// Used by the wrapper / future web UI to surface a "no wallet
/// configured" state.
pub async fn payment_methods(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = btcpay_cfg::load(&state.db)
.await
.map_err(AppError::Internal)?
.ok_or(AppError::BtcpayNotConfigured)?;
let methods = btcpay_client::list_payment_methods(&cfg.base_url, &cfg.api_key, &cfg.store_id)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e}")))?;
// Return both the raw array for callers that want detail, and a
// boolean summary for the common "is anything configured?" check.
let count = methods.len();
Ok(Json(json!({
"store_id": cfg.store_id,
"count": count,
"methods": methods,
})))
}
/// Admin endpoint: report current BTCPay connection status.
pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = btcpay_cfg::load(&state.db).await.map_err(AppError::Internal)?;
Ok(Json(match cfg {
None => json!({ "connected": false }),
Some(c) => json!({
"connected": true,
"store_id": c.store_id,
"webhook_id": c.webhook_id,
"base_url": c.base_url,
}),
}))
}
// --- internals ---
async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> {
btcpay_cfg::consume_authorize_state(&state.db, state_token)
.await
.map_err(|_| AppError::Unauthorized)?;
let base_url = &state.config.btcpay_url;
// Enumerate stores the key has access to. With `selectiveStores=true`
// the operator picked specific stores during authorize; we pick the
// first one that the key can see.
let stores = btcpay_client::list_stores(base_url, api_key)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e}")))?;
let store = stores
.into_iter()
.next()
.ok_or_else(|| AppError::BadRequest(
"The authorized API key has access to zero stores. Re-run connect and pick a store.".into()
))?;
// Generate a strong webhook secret, then register the webhook on BTCPay.
let mut raw_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw_secret);
let webhook_secret = BASE32_NOPAD.encode(&raw_secret);
let callback_url = format!("{}/v1/btcpay/webhook", state.config.public_base_url);
let created_webhook = btcpay_client::create_webhook(
base_url,
api_key,
&store.id,
&callback_url,
&webhook_secret,
)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e}")))?;
// Persist.
let cfg = btcpay_cfg::BtcpayConfig {
base_url: base_url.clone(),
api_key: api_key.to_string(),
store_id: store.id.clone(),
webhook_id: Some(created_webhook.id.clone()),
webhook_secret: webhook_secret.clone(),
};
btcpay_cfg::save(&state.db, &cfg)
.await
.map_err(AppError::Internal)?;
// Swap runtime — wrap a fresh BtcpayProvider into the
// PaymentProvider trait object held by AppState. Pass the
// public-facing BTCPay URL too so that checkout URLs returned to
// buyers get rewritten from the internal Docker hostname to a
// browser-reachable host.
let client = BtcpayClient::new(base_url, api_key, &store.id);
let provider = Arc::new(
BtcpayProvider::new(client, webhook_secret)
.with_public_base(state.config.btcpay_public_url.clone()),
);
state.set_payment_provider(provider).await;
tracing::info!(
store = %store.id,
store_name = %store.name,
webhook_id = %created_webhook.id,
"BTCPay connected via authorize flow"
);
Ok(())
}
fn success_page(msg: &str) -> Response {
let body = format!(
r#"<!doctype html><html><head><meta charset="utf-8"><title>BTCPay connected</title>
<style>body{{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:1rem;line-height:1.5}}
h2{{color:#0a7}}</style></head>
<body><h2>✓ {msg}</h2></body></html>"#,
msg = html_escape::encode_text(msg)
);
(StatusCode::OK, Html(body)).into_response()
}
/// Admin endpoint: disconnect BTCPay. Best-effort revocation of the
/// webhook + API key on BTCPay's side, then unconditional clear of the
/// local config row. If BTCPay is unreachable, the local state is still
/// cleared and the operator gets a warning to clean up BTCPay manually.
pub async fn disconnect(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = crate::api::admin::request_context(&headers);
let cfg = btcpay_cfg::load(&state.db)
.await
.map_err(AppError::Internal)?;
let Some(cfg) = cfg else {
return Ok(Json(json!({
"ok": true,
"noop": true,
"message": "BTCPay was not connected; nothing to do.",
})));
};
// Capture metadata for the response BEFORE we clear local state.
let store_id = cfg.store_id.clone();
let webhook_id = cfg.webhook_id.clone();
// Best-effort remote cleanup. We DON'T short-circuit if either of
// these calls fails — the operator's intent is to disconnect, and
// leaving local state pointing at a remote we no longer trust is
// worse than leaving orphan state on the BTCPay side. Any failures
// are surfaced in the response so the operator can manually clean
// up on BTCPay if needed.
let mut warnings: Vec<String> = Vec::new();
if let Some(webhook_id) = webhook_id.as_deref() {
if let Err(e) = btcpay_client::delete_webhook(
&cfg.base_url,
&cfg.api_key,
&cfg.store_id,
webhook_id,
)
.await
{
warnings.push(format!(
"Could not delete BTCPay webhook {webhook_id}: {e}. \
You may want to manually delete it in BTCPay's store webhook settings."
));
}
}
if let Err(e) = btcpay_client::revoke_api_key(&cfg.base_url, &cfg.api_key).await {
warnings.push(format!(
"Could not revoke BTCPay API key: {e}. \
You may want to manually revoke it in BTCPay's account API-keys page."
));
}
btcpay_cfg::clear(&state.db)
.await
.map_err(AppError::Internal)?;
// Replace the runtime payment provider so subsequent purchase
// attempts return BtcpayNotConfigured cleanly.
state.clear_payment_provider().await;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"btcpay.disconnect",
Some("btcpay_config"),
None,
ip.as_deref(),
ua.as_deref(),
&json!({ "store_id": store_id, "webhook_id": webhook_id }),
)
.await;
Ok(Json(json!({
"ok": true,
"noop": false,
"store_id": store_id,
"webhook_id": webhook_id,
"warnings": warnings,
})))
}
+640
View File
@@ -0,0 +1,640 @@
//! Public buyer-facing purchase page at `GET /buy/:slug`.
//!
//! The flow is:
//! 1. Buyer hits `https://<operator-keysat>/buy/<product-slug>` in a browser.
//! 2. We look up the product, render an HTML page showing what they're
//! buying — name, description, price — plus a small form for an
//! optional email (for receipt + license delivery) and an optional
//! discount code.
//! 3. They click "Pay with Bitcoin." Inline JS POSTs to `/v1/purchase`,
//! gets back a BTCPay checkout URL, redirects the browser there.
//! 4. After payment, BTCPay redirects to `/thank-you` (existing handler).
//!
//! Visual language matches the rest of the Keysat design system: navy
//! topbar, cream paper-textured background, gold accent on the price and
//! the CTA, classical type. Inlined CSS so this single file is the whole
//! buyer-facing surface — easy to deploy, no asset hosting required.
use crate::api::AppState;
use crate::db::repo;
use axum::{
extract::{Path, State},
http::StatusCode,
response::Html,
};
pub async fn render(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Html<String>, (StatusCode, Html<String>)> {
// Look up the product. Inactive or missing → 404 with a friendly page.
let product = match repo::get_product_by_slug(&state.db, &slug).await {
Ok(Some(p)) if p.active => p,
_ => return Err((StatusCode::NOT_FOUND, Html(not_found_html(&slug)))),
};
// Live-read operator name (same pattern as thank-you / root).
let live = repo::settings_get(&state.db, crate::api::admin::SETTING_OPERATOR_NAME)
.await
.ok()
.flatten();
let operator_str = live
.as_deref()
.or(state.config.operator_name.as_deref())
.unwrap_or("Keysat");
let operator = html_escape(operator_str);
let product_name = html_escape(&product.name);
let product_slug = html_escape(&product.slug);
let product_description = html_escape(&product.description);
let price_sats_fmt = format_thousands(product.price_sats);
let body = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Buy {product_name} — {operator}</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {{
--navy-950:#0E1F33; --navy-900:#142A47; --navy-800:#1E3A5F;
--cream-50:#FBF9F2; --cream-100:#F5F1E8; --cream-200:#EDE7D7;
--gold-700:#8A6F3D; --gold-500:#BFA068; --gold-400:#D4B985;
--ink-900:#0E1F33; --ink-700:#2C3E54; --ink-500:#5A6B7F;
--success:#2D7A5F; --success-bg:#E3F0EA;
--danger:#B23A3A; --danger-bg:#F4E0E0;
--border-1:rgba(14,31,51,0.12);
--border-2:rgba(14,31,51,0.20);
--font-display:'Manrope','Helvetica Neue',Arial,sans-serif;
--font-body:'Inter','Helvetica Neue',Arial,sans-serif;
--font-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;
--shadow-md:0 2px 4px rgba(14,31,51,0.06),0 4px 12px rgba(14,31,51,0.06);
}}
*{{box-sizing:border-box}} html,body{{margin:0;padding:0}}
body {{
font-family:var(--font-body); color:var(--ink-900);
background:var(--cream-100);
background-image:
radial-gradient(rgba(14,31,51,0.025) 1px, transparent 1px),
radial-gradient(rgba(138,111,61,0.022) 1px, transparent 1px);
background-size:3px 3px, 7px 7px;
-webkit-font-smoothing:antialiased; min-height:100vh;
}}
.topbar {{
background:rgba(245,241,232,0.85); backdrop-filter:blur(10px);
border-bottom:1px solid var(--border-1);
padding:14px 24px;
}}
.topbar .inner {{
max-width:680px; margin:0 auto;
display:flex; align-items:center; gap:12px;
font-family:var(--font-display); font-weight:500; font-size:14px;
letter-spacing:0.28em; text-transform:uppercase; color:var(--navy-900);
}}
.topbar .operator {{
font-family:var(--font-body); font-size:12px;
letter-spacing:0.04em; text-transform:none;
color:var(--ink-500);
margin-left:auto;
}}
.wrap {{ max-width:560px; margin:48px auto; padding:0 24px; }}
.eyebrow {{
font-size:11.5px; font-weight:700; letter-spacing:0.18em;
text-transform:uppercase; color:var(--gold-700); margin-bottom:14px;
display:inline-flex; align-items:center; gap:10px;
}}
.eyebrow::before {{ content:''; display:inline-block; width:28px; height:1px; background:var(--gold-500); }}
h1 {{
font-family:var(--font-display); font-weight:500; font-size:42px;
line-height:1.05; letter-spacing:-0.022em; color:var(--navy-950);
margin:0 0 12px;
}}
.product-slug {{
font-family:var(--font-mono); font-size:12.5px; color:var(--ink-500);
margin:0 0 18px;
}}
.description {{
font-size:16px; line-height:1.55; color:var(--ink-700);
margin:0 0 32px;
}}
.cert {{
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:14px;
box-shadow:0 0 0 1px var(--gold-500) inset, var(--shadow-md);
padding:32px 32px 28px;
position:relative;
margin-bottom:24px;
}}
.cert::before, .cert::after {{
content:''; position:absolute; left:14px; right:14px;
height:1px; background:var(--gold-500); opacity:0.5;
}}
.cert::before {{ top:14px; }} .cert::after {{ bottom:14px; }}
.price {{
font-family:var(--font-display); font-weight:700; font-size:36px;
color:var(--navy-950); letter-spacing:-0.025em; margin:8px 0 0;
}}
.price .unit {{
font-family:var(--font-body); font-size:15px; font-weight:600;
color:var(--ink-500); margin-left:8px;
}}
.price-label {{
font-size:11.5px; font-weight:700; letter-spacing:0.14em;
text-transform:uppercase; color:var(--ink-500);
}}
.field {{ margin-bottom:14px; }}
.field label {{
display:block; font-size:12.5px; font-weight:600;
color:var(--ink-700); margin-bottom:6px;
}}
.field input {{
width:100%; padding:11px 13px;
font-family:var(--font-body); font-size:14px;
border:1px solid var(--border-2); border-radius:8px;
background:#fff; color:var(--ink-900);
}}
.field input:focus {{
outline:none; border-color:var(--navy-700);
box-shadow:0 0 0 3px rgba(30,58,95,0.18);
}}
.field .hint {{ font-size:12px; color:var(--ink-500); margin-top:5px; }}
/* Apply-discount cluster: input + button on one row */
.code-row {{ display:flex; gap:8px; align-items:stretch; }}
.code-row input {{ flex:1; }}
.btn-apply {{
background:transparent; color:var(--navy-800);
border:1px solid var(--border-2); border-radius:8px;
padding:0 16px;
font-family:var(--font-body); font-weight:600; font-size:13px;
cursor:pointer; transition:all 120ms;
flex-shrink:0;
}}
.btn-apply:hover {{ background:var(--cream-200); border-color:var(--navy-700); }}
.btn-apply:disabled {{ opacity:0.5; cursor:wait; }}
.code-status {{
margin-top:8px; font-size:13px; padding:8px 12px;
border-radius:7px; display:none;
}}
.code-status.show {{ display:block; }}
.code-status.ok {{ background:var(--success-bg); color:#205c47; border:1px solid rgba(45,122,95,0.25); }}
.code-status.bad {{ background:var(--danger-bg); color:#8a2828; border:1px solid rgba(178,58,58,0.25); }}
/* Price card update animation when discount applied */
.price-strike {{
text-decoration:line-through; color:var(--ink-500);
font-size:18px; font-weight:500; display:block;
margin-bottom:4px;
}}
.price-discount-tag {{
display:inline-block; margin-left:8px;
font-family:var(--font-body); font-size:12px; font-weight:600;
padding:3px 10px; border-radius:999px;
background:var(--success-bg); color:#205c47;
border:1px solid rgba(45,122,95,0.25);
vertical-align:middle;
}}
.btn-pay {{
width:100%; padding:14px;
background:var(--navy-800); color:var(--cream-50);
border:0; border-radius:10px;
font-family:var(--font-body); font-weight:600; font-size:15px;
cursor:pointer; transition:background 120ms;
margin-top:16px;
display:inline-flex; align-items:center; justify-content:center; gap:8px;
}}
.btn-pay:hover {{ background:var(--navy-900); }}
.btn-pay:disabled {{ opacity:0.6; cursor:wait; }}
.btn-pay svg {{ width:18px; height:18px; }}
.error {{
margin-top:14px; padding:10px 14px;
background:var(--danger-bg); color:#8a2828;
border:1px solid rgba(178,58,58,0.25);
border-radius:7px; font-size:13.5px;
display:none;
}}
.error.show {{ display:block; }}
.license-success {{
display:none; margin-top:24px;
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:14px;
box-shadow:0 0 0 1px var(--gold-500) inset, 0 8px 16px rgba(14,31,51,0.10);
padding:32px 32px 28px; position:relative;
}}
.license-success.show {{ display:block; }}
.license-success::before, .license-success::after {{
content:''; position:absolute; left:14px; right:14px;
height:1px; background:var(--gold-500); opacity:0.5;
}}
.license-success::before {{ top:14px; }}
.license-success::after {{ bottom:14px; }}
.license-success .stamp {{
font-size:10px; font-weight:700; letter-spacing:0.22em;
text-transform:uppercase; color:var(--gold-700);
text-align:center; margin-bottom:16px;
}}
.license-success h3 {{
font-family:var(--font-display); font-weight:500; font-size:22px;
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em;
text-align:center;
}}
.license-success .subtitle {{
font-size:14px; color:var(--ink-500); text-align:center;
margin:0 0 22px;
}}
.license-success .field-label {{
font-size:11px; font-weight:600; letter-spacing:0.12em;
text-transform:uppercase; color:var(--ink-500); margin-bottom:6px;
}}
.license-success .key-box {{
background:var(--navy-950); color:var(--cream-50);
padding:14px 16px; border-radius:8px;
font-family:var(--font-mono); font-size:12.5px;
word-break:break-all; line-height:1.5;
display:flex; align-items:flex-start; gap:12px;
}}
.license-success .key-box .key-text {{ flex:1; }}
.license-success .key-box button {{
background:rgba(245,241,232,0.10); color:var(--cream-50);
border:0; padding:6px 10px; border-radius:6px;
font-family:var(--font-body); font-size:11.5px; cursor:pointer;
flex-shrink:0;
}}
.license-success .key-box button:hover {{ background:rgba(245,241,232,0.20); }}
.license-success .save-note {{
margin-top:14px; font-size:13px; color:var(--ink-700);
background:var(--cream-100); border:1px solid var(--border-1);
border-radius:8px; padding:10px 14px;
}}
.license-success .save-note strong {{ color:var(--navy-950); }}
footer.kfooter {{
text-align:center; font-size:12px; color:var(--ink-500);
margin-top:48px; padding:18px;
}}
footer.kfooter a {{ color:var(--ink-500); text-decoration:none; }}
footer.kfooter a:hover {{ color:var(--navy-900); }}
</style>
</head>
<body>
<div class="topbar">
<div class="inner">
<span>Keysat</span>
<span class="operator">Sold by {operator}</span>
</div>
</div>
<div class="wrap">
<div class="eyebrow">Buy a license</div>
<h1>{product_name}</h1>
<div class="product-slug">{product_slug}</div>
<p class="description">{product_description}</p>
<div class="cert">
<div class="price-label">Price</div>
<div class="price" id="price-display">
<span id="price-strike-line" class="price-strike" style="display:none"></span>
<span id="price-current">{price_sats_fmt}</span><span class="unit">sats</span>
<span id="price-discount-tag" class="price-discount-tag" style="display:none"></span>
</div>
</div>
<form id="buy-form">
<div class="field">
<label for="email">Email (for receipt &amp; license)</label>
<input type="email" id="email" name="email" placeholder="you@example.com" required>
<div class="hint">We&rsquo;ll send your license key here after payment confirms.</div>
</div>
<div class="field">
<label for="code">Discount code (optional)</label>
<div class="code-row">
<input type="text" id="code" name="code" placeholder="FOUNDERS50" autocomplete="off">
<button type="button" class="btn-apply" id="btn-apply">Apply</button>
</div>
<div class="code-status" id="code-status" role="status" aria-live="polite"></div>
</div>
<button type="submit" class="btn-pay" id="btn-pay">
<svg id="btn-pay-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.5 8.5h5a2 2 0 010 4h-5m0 0h5a2 2 0 010 4h-5m0-8v8m2-10v2m0 8v2"></path>
</svg>
<span id="btn-pay-label">Pay with Bitcoin</span>
</button>
<div class="error" id="err"></div>
</form>
<div class="license-success" id="license-success" role="region" aria-label="License issued">
<div class="stamp">&mdash; License issued &mdash;</div>
<h3>You&rsquo;re licensed.</h3>
<p class="subtitle">No payment needed for this code. Your signed license is below.</p>
<div class="field-label">License key</div>
<div class="key-box">
<span class="key-text" id="license-key-text">&hellip;</span>
<button id="license-key-copy">Copy</button>
</div>
<div class="save-note">
<strong>Save this somewhere safe.</strong> The license key is signed at issue time and verifies offline. We&rsquo;ll also send a copy to <span id="license-email-display"></span> for your records.
</div>
</div>
</div>
<footer class="kfooter">
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-paid software licensing</span>
</footer>
<script>
(function() {{
const form = document.getElementById('buy-form');
const btn = document.getElementById('btn-pay');
const btnLabel = document.getElementById('btn-pay-label');
const btnIcon = document.getElementById('btn-pay-icon');
const errEl = document.getElementById('err');
const successEl = document.getElementById('license-success');
const keyTextEl = document.getElementById('license-key-text');
const emailDisplayEl = document.getElementById('license-email-display');
const codeInput = document.getElementById('code');
const applyBtn = document.getElementById('btn-apply');
const codeStatus = document.getElementById('code-status');
const priceCurrent = document.getElementById('price-current');
const priceStrike = document.getElementById('price-strike-line');
const priceTag = document.getElementById('price-discount-tag');
const PRODUCT_SLUG = {slug_json};
const BASE_PRICE_FMT = priceCurrent.textContent;
// State of the most recent successful Apply. When set with kind=free_license
// and the same code is still in the input, the submit handler skips the
// "try /v1/redeem then fall through" dance and goes straight to redeem.
let appliedCode = null; // {{ code, kind, is_free, final_price_sats }}
function showError(msg) {{
errEl.textContent = msg;
errEl.classList.add('show');
}}
function clearError() {{ errEl.classList.remove('show'); }}
function showLicense(licenseKey, email) {{
keyTextEl.textContent = licenseKey;
emailDisplayEl.textContent = email || '(no email provided)';
form.style.display = 'none';
successEl.classList.add('show');
successEl.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
}}
function fmtNum(n) {{
return Number(n).toLocaleString('en-US');
}}
function setStatus(kind, text) {{
codeStatus.classList.remove('ok', 'bad');
if (!kind) {{ codeStatus.classList.remove('show'); codeStatus.textContent = ''; return; }}
codeStatus.classList.add(kind === 'ok' ? 'ok' : 'bad', 'show');
codeStatus.textContent = text;
}}
function resetPrice() {{
priceCurrent.textContent = BASE_PRICE_FMT;
priceStrike.style.display = 'none';
priceStrike.textContent = '';
priceTag.style.display = 'none';
priceTag.textContent = '';
}}
function setPaidButton() {{
btnLabel.textContent = 'Pay with Bitcoin';
btnIcon.style.display = '';
}}
function setRedeemButton() {{
btnLabel.textContent = 'Redeem license';
btnIcon.style.display = 'none';
}}
// Reset apply state if the buyer edits the code after a successful Apply.
codeInput.addEventListener('input', function() {{
if (appliedCode && codeInput.value.trim().toUpperCase() !== appliedCode.code.toUpperCase()) {{
appliedCode = null;
resetPrice();
setPaidButton();
setStatus(null);
}}
}});
applyBtn.addEventListener('click', async function() {{
clearError();
const code = codeInput.value.trim();
if (!code) {{
setStatus('bad', 'Enter a code first.');
return;
}}
applyBtn.disabled = true;
const orig = applyBtn.textContent;
applyBtn.textContent = 'Checking…';
try {{
const url = '/v1/discount-codes/preview?code='
+ encodeURIComponent(code) + '&product=' + encodeURIComponent(PRODUCT_SLUG);
const resp = await fetch(url);
if (!resp.ok) {{
let msg = 'HTTP ' + resp.status;
try {{ const j = await resp.json(); msg = j.message || j.error || msg; }} catch(_) {{}}
throw new Error(msg);
}}
const j = await resp.json();
if (!j.valid) {{
appliedCode = null;
resetPrice();
setPaidButton();
setStatus('bad', j.message || 'Code not valid.');
return;
}}
appliedCode = {{
code: j.code,
kind: j.kind,
is_free: !!j.is_free,
final_price_sats: j.final_price_sats,
}};
// Update price card
if (j.kind === 'free_license' || j.final_price_sats === 0) {{
priceStrike.textContent = fmtNum(j.base_price_sats) + ' sats';
priceStrike.style.display = 'block';
priceCurrent.textContent = 'FREE';
priceTag.textContent = '100% off';
priceTag.style.display = 'inline-block';
setRedeemButton();
}} else {{
priceStrike.textContent = fmtNum(j.base_price_sats) + ' sats';
priceStrike.style.display = 'block';
priceCurrent.textContent = fmtNum(j.final_price_sats);
if (j.kind === 'percent') {{
priceTag.textContent = (j.amount_pct || ((j.discount_applied_sats / j.base_price_sats) * 100).toFixed(0)) + '% off';
}} else {{
priceTag.textContent = fmtNum(j.discount_applied_sats) + ' sats off';
}}
priceTag.style.display = 'inline-block';
setPaidButton();
}}
setStatus('ok', j.message || 'Code applied.');
}} catch (err) {{
appliedCode = null;
resetPrice();
setPaidButton();
setStatus('bad', err.message || 'Could not validate code.');
}} finally {{
applyBtn.disabled = false;
applyBtn.textContent = orig;
}}
}});
// Try free-license redemption first if a code was provided. If that
// path returns "this code requires payment", fall through to the
// BTCPay flow with the code applied. Any other error stops here.
async function tryFreeRedeem(code, email) {{
const resp = await fetch('/v1/redeem', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{
product: PRODUCT_SLUG,
code,
buyer_email: email || undefined,
}}),
}});
if (resp.ok) {{
const j = await resp.json();
return {{ ok: true, license_key: j.license_key }};
}}
let msg = 'HTTP ' + resp.status;
try {{
const j = await resp.json();
msg = j.message || j.error || msg;
}} catch (_) {{}}
// Distinguish "fall through to paid flow" from real errors.
if (resp.status === 400 && /requires payment/i.test(msg)) {{
return {{ ok: false, fallThrough: true }};
}}
return {{ ok: false, fallThrough: false, msg }};
}}
async function startPaidPurchase(code, email) {{
const body = {{ product: PRODUCT_SLUG, buyer_email: email || undefined }};
if (code) body.code = code;
const resp = await fetch('/v1/purchase', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(body),
}});
if (!resp.ok) {{
let msg = 'HTTP ' + resp.status;
try {{
const j = await resp.json();
msg = j.message || j.error || msg;
}} catch (_) {{}}
throw new Error(msg);
}}
const j = await resp.json();
if (!j.checkout_url) throw new Error('No checkout URL returned by server');
window.location.href = j.checkout_url;
}}
// "Copy" on the license key box.
document.getElementById('license-key-copy').addEventListener('click', async function() {{
try {{
await navigator.clipboard.writeText(keyTextEl.textContent);
this.textContent = 'Copied';
setTimeout(() => {{ this.textContent = 'Copy'; }}, 1400);
}} catch (e) {{}}
}});
form.addEventListener('submit', async function(e) {{
e.preventDefault();
clearError();
btn.disabled = true;
const originalLabel = btnLabel.textContent;
btnLabel.textContent = 'Working…';
const email = document.getElementById('email').value.trim();
const code = codeInput.value.trim();
const codeMatchesApplied = appliedCode &&
code.toUpperCase() === appliedCode.code.toUpperCase();
try {{
// Fast path: a free_license code was already validated via Apply.
if (codeMatchesApplied && appliedCode.is_free) {{
const r = await tryFreeRedeem(code, email);
if (r.ok) {{ showLicense(r.license_key, email); return; }}
// If the server changed its mind, surface the error rather than silently
// routing to a paid flow that the buyer didn't consent to.
throw new Error(r.msg || 'Could not redeem free license.');
}}
// Slower path (no Apply or non-free code): keep the original try-then-fallthrough.
if (code) {{
const r = await tryFreeRedeem(code, email);
if (r.ok) {{ showLicense(r.license_key, email); return; }}
if (!r.fallThrough) {{
throw new Error(r.msg || 'Code rejected');
}}
// else fall through to the BTCPay path with the code applied
}}
btnLabel.textContent = 'Creating invoice…';
await startPaidPurchase(code, email);
}} catch (err) {{
showError('Could not complete: ' + (err.message || err));
btn.disabled = false;
btnLabel.textContent = originalLabel;
}}
}});
}})();
</script>
</body>
</html>
"#,
operator = operator,
product_name = product_name,
product_slug = product_slug,
product_description = product_description,
price_sats_fmt = price_sats_fmt,
slug_json = serde_json::to_string(&product.slug).unwrap_or_else(|_| "\"\"".into()),
);
Ok(Html(body))
}
fn not_found_html(slug: &str) -> String {
let slug_safe = html_escape(slug);
format!(
r#"<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Product not found</title>
<style>
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
max-width:32rem;margin:4rem auto;padding:0 1.25rem;color:#222;background:#fafafa;line-height:1.55}}
h1{{font-size:1.5rem;margin-top:0}}
code{{background:#eee;padding:0.1em 0.4em;border-radius:4px;font-family:ui-monospace,monospace}}
</style></head>
<body>
<h1>Product not found</h1>
<p>No product is registered under the slug <code>{slug_safe}</code>, or it&rsquo;s currently inactive.</p>
<p>If you arrived here from a link the seller shared, double-check that you&rsquo;ve typed the URL correctly. Otherwise, ask the seller to confirm the product slug.</p>
</body></html>"#,
slug_safe = slug_safe
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn format_thousands(n: i64) -> String {
// Renders 50000 as "50,000" — visible price legibility for sat amounts.
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(',');
}
out.push(c);
}
out.chars().rev().collect()
}
+354
View File
@@ -0,0 +1,354 @@
//! Admin endpoints for discount / referral codes.
//!
//! Operators create codes, list them with usage stats, and disable them.
//! The public purchase flow consumes codes via the `code` field on
//! `POST /v1/purchase`; that path is handled in `crate::api::purchase`.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct CreateDiscountCodeReq {
/// e.g. "FOUNDERS50". Normalized to uppercase. ASCII alphanumerics + '-' '_'.
pub code: String,
/// 'percent' | 'fixed_sats'.
pub kind: String,
/// Basis points if kind == 'percent' (0..=10000); sats if kind == 'fixed_sats'.
pub amount: i64,
#[serde(default)]
pub max_uses: Option<i64>,
/// ISO-8601 RFC3339 UTC timestamp.
#[serde(default)]
pub expires_at: Option<String>,
/// Restrict to a single product (by slug). Omit for any product.
#[serde(default)]
pub product_slug: Option<String>,
/// Restrict to a single policy (by slug + product_slug). Omit for any policy.
/// Requires `product_slug` to be set if specified.
#[serde(default)]
pub policy_slug: Option<String>,
/// Optional free-form tag for tracking, e.g. 'launch-twitter', 'alice@example.com'.
#[serde(default)]
pub referrer_label: Option<String>,
#[serde(default)]
pub description: String,
}
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateDiscountCodeReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// Resolve product/policy slugs to ids if supplied.
let product_id = if let Some(slug) = req.product_slug.as_deref() {
let p = repo::get_product_by_slug(&state.db, slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{slug}'")))?;
Some(p.id)
} else {
None
};
let policy_id = if let Some(slug) = req.policy_slug.as_deref() {
let pid = product_id.as_deref().ok_or_else(|| {
AppError::BadRequest("policy_slug requires product_slug".into())
})?;
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{slug}' for product '{}'",
req.product_slug.as_deref().unwrap_or("")
))
})?;
Some(policy.id)
} else {
None
};
let code = repo::create_discount_code(
&state.db,
&req.code,
&req.kind,
req.amount,
req.max_uses,
req.expires_at.as_deref(),
product_id.as_deref(),
policy_id.as_deref(),
req.referrer_label.as_deref(),
&req.description,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"discount_code.create",
Some("discount_code"),
Some(&code.id),
ip.as_deref(),
ua.as_deref(),
&json!({
"code": code.code,
"kind": code.kind,
"amount": code.amount,
"max_uses": code.max_uses,
"expires_at": code.expires_at,
"product_id": product_id,
"policy_id": policy_id,
"referrer_label": code.referrer_label,
}),
)
.await;
Ok(Json(json!(code)))
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub include_inactive: bool,
}
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let codes = repo::list_discount_codes(&state.db, !q.include_inactive).await?;
Ok(Json(json!({ "codes": codes })))
}
pub async fn get_one(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let code = repo::get_discount_code_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("discount code {id}")))?;
let redemptions = repo::list_redemptions_by_code(&state.db, &code.id).await?;
Ok(Json(json!({
"code": code,
"redemptions": redemptions,
})))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_discount_code_active(&state.db, &id, req.active).await?;
let action = if req.active {
"discount_code.enable"
} else {
"discount_code.disable"
};
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
action,
Some("discount_code"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
/// Hard-delete a discount code. Refuses if any redemptions reference
/// the code — those rows are part of the audit trail and shouldn't be
/// orphaned. For codes that have been used, the operator should
/// disable instead.
pub async fn delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// Look up the code so we can audit-log meaningful detail.
let code = repo::get_discount_code_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("discount code '{id}'")))?;
// Refuse if any redemptions exist (referential integrity + audit
// trail preservation). Operator should use Disable in that case.
let redemption_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM discount_redemptions WHERE code_id = ?",
)
.bind(&id)
.fetch_one(&state.db)
.await?;
if redemption_count > 0 {
return Err(AppError::Conflict(format!(
"cannot delete code '{}' — it has {} redemption(s) on the audit trail. \
Disable it instead (it stops accepting new uses, but the history is kept).",
code.code, redemption_count
)));
}
sqlx::query("DELETE FROM discount_codes WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"discount_code.delete",
Some("discount_code"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "code": code.code, "kind": code.kind }),
)
.await;
Ok(Json(json!({ "ok": true, "deleted": code.code })))
}
#[derive(Debug, Deserialize)]
pub struct PreviewQuery {
pub code: String,
pub product: String,
}
/// PUBLIC endpoint — buyers hit this from the buy page when they click
/// Apply on a discount code. Validates the code (existence, active
/// state, expiry, product applicability) and returns the kind +
/// computed discounted price WITHOUT consuming a slot. The actual
/// purchase / redemption still goes through `/v1/purchase` or
/// `/v1/redeem` and is the real transaction; this is just for showing
/// the buyer what they'll be charged before they commit.
pub async fn preview(
State(state): State<AppState>,
Query(q): Query<PreviewQuery>,
) -> AppResult<Json<Value>> {
let code_str = q.code.trim();
if code_str.is_empty() {
return Err(AppError::BadRequest("code is required".into()));
}
let product = repo::get_product_by_slug(&state.db, &q.product)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product)))?;
let code = match repo::get_discount_code_by_code(&state.db, code_str).await? {
Some(c) => c,
None => {
return Ok(Json(json!({
"valid": false,
"reason": "unknown_code",
"message": "Code not found.",
"base_price_sats": product.price_sats,
})));
}
};
if !code.active {
return Ok(Json(json!({
"valid": false,
"reason": "disabled",
"message": "This code has been disabled.",
"base_price_sats": product.price_sats,
})));
}
if let Some(exp) = &code.expires_at {
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
return Ok(Json(json!({
"valid": false,
"reason": "expired",
"message": "This code has expired.",
"base_price_sats": product.price_sats,
})));
}
}
}
if let Some(pid) = &code.applies_to_product_id {
if pid != &product.id {
return Ok(Json(json!({
"valid": false,
"reason": "wrong_product",
"message": "This code does not apply to this product.",
"base_price_sats": product.price_sats,
})));
}
}
if let Some(max) = code.max_uses {
if code.used_count >= max {
return Ok(Json(json!({
"valid": false,
"reason": "exhausted",
"message": "This code has reached its use limit.",
"base_price_sats": product.price_sats,
})));
}
}
// Compute the discounted price (mirroring purchase.rs's logic).
let base = product.price_sats;
let (final_price, discount_applied) = match code.kind.as_str() {
"free_license" => (0i64, base),
"percent" => {
let bps = (code.amount).clamp(0, 10_000) as i128;
let b = base as i128;
let discount = ((b * bps) / 10_000).max(0).min(b) as i64;
((base - discount).max(1), discount)
}
"fixed_sats" => {
let discount = code.amount.max(0).min(base);
((base - discount).max(1), discount)
}
_ => (base, 0),
};
let amount_pct = if code.kind == "percent" {
Some(code.amount as f64 / 100.0)
} else {
None
};
Ok(Json(json!({
"valid": true,
"code": code.code,
"kind": code.kind,
"is_free": code.kind == "free_license",
"base_price_sats": base,
"discount_applied_sats": discount_applied,
"final_price_sats": if code.kind == "free_license" { 0 } else { final_price },
"amount_pct": amount_pct,
"message": match code.kind.as_str() {
"free_license" => "Free license — no payment required.".to_string(),
"percent" => format!("{}% off applied.", code.amount as f64 / 100.0),
"fixed_sats" => format!("{} sats off applied.", code.amount),
_ => "Code applied.".to_string(),
},
})))
}
+150
View File
@@ -0,0 +1,150 @@
//! Admin-only issuer-key import endpoint.
//!
//! Used exactly once, by exactly one operator: when bootstrapping a
//! "master Keysat" instance (the one that issues licenses for the Keysat
//! package itself). The master operator pre-generated an Ed25519 keypair
//! offline; this endpoint takes the PEM-encoded private half and stores
//! it as the daemon's signing keypair, replacing the auto-generated one
//! that gets created on first boot.
//!
//! ## Why this isn't a StartOS Action
//!
//! 95% of Keysat operators install Keysat to sell their own software.
//! Their auto-generated issuer key is exactly what they want; they never
//! need this endpoint. Surfacing an "import issuer key" button in every
//! operator's StartOS Actions tab would create cognitive load (am I
//! supposed to do this?) for zero benefit. So this lives as an admin
//! API endpoint only — invisible by default, callable via curl during
//! the master-bootstrap procedure documented in
//! `MASTER_KEYPAIR_PROCEDURE.md`.
//!
//! ## Safety guards
//!
//! Replacing the issuer key after licenses have been issued would
//! invalidate every previously-signed customer license. To prevent that
//! footgun, the endpoint refuses if any license rows exist in the
//! database. The master Keysat instance hasn't issued anything when it
//! gets bootstrapped, so this guard never trips during legitimate use
//! and prevents the worst-case mistake.
//!
//! ## After successful import
//!
//! The new keypair lands in the `server_keys` table immediately, but the
//! daemon's in-memory `AppState.keypair` still holds the old one until
//! restart. The endpoint returns a `restart_required: true` so the
//! operator (or their orchestration) knows to bounce the service before
//! the new key takes effect.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use axum::{body::Bytes, extract::State, http::HeaderMap, Json};
use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey};
use ed25519_dalek::SigningKey;
use serde_json::{json, Value};
pub async fn import(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let pem = std::str::from_utf8(&body)
.map_err(|_| AppError::BadRequest("body is not valid UTF-8".into()))?
.trim();
if pem.is_empty() {
return Err(AppError::BadRequest("body is empty".into()));
}
if !pem.contains("-----BEGIN") || !pem.contains("PRIVATE KEY-----") {
return Err(AppError::BadRequest(
"expected a PEM-encoded private key (must contain BEGIN/END PRIVATE KEY)".into(),
));
}
// Parse + validate the supplied PEM.
let signing = SigningKey::from_pkcs8_pem(pem).map_err(|e| {
AppError::BadRequest(format!("could not parse Ed25519 private key: {e}"))
})?;
let verifying = signing.verifying_key();
// Re-encode through pkcs8 so we always store a normalized form. This
// also catches any encoding oddity on the input side that would have
// tripped a future load.
use pkcs8::LineEnding;
let priv_pem = signing
.to_pkcs8_pem(LineEnding::LF)
.map_err(|e| AppError::Internal(anyhow::anyhow!("re-encode private key: {e}")))?
.to_string();
let pub_pem = verifying
.to_public_key_pem(LineEnding::LF)
.map_err(|e| AppError::Internal(anyhow::anyhow!("encode public key: {e}")))?;
// Safety guard: refuse if any licenses have already been issued by
// this Keysat. Replacing the issuer key would invalidate them.
let licenses_exist: bool =
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM licenses LIMIT 1)")
.fetch_one(&state.db)
.await?;
if licenses_exist {
return Err(AppError::Conflict(
"this Keysat has already issued at least one license; importing a new \
issuer key would invalidate every previously-signed license. Refusing. \
Use this endpoint only on a fresh master-Keysat install before any \
licenses have been issued."
.into(),
));
}
// Upsert the keypair into server_keys row id=1. SQLite's INSERT ON
// CONFLICT is the idiomatic way to do this in one statement.
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO server_keys (id, algorithm, public_key_pem, private_key_pem, created_at)
VALUES (1, 'ed25519', ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
algorithm = excluded.algorithm,
public_key_pem = excluded.public_key_pem,
private_key_pem = excluded.private_key_pem,
created_at = excluded.created_at",
)
.bind(&pub_pem)
.bind(&priv_pem)
.bind(&now)
.execute(&state.db)
.await?;
// Audit-log this prominently. There is no scenario where a regular
// operator should be running this; if it shows up in the audit log
// unexpectedly, that's a red flag worth investigating.
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"issuer_key.import",
Some("server_key"),
None,
ip.as_deref(),
ua.as_deref(),
&json!({
"public_key_pem": pub_pem,
"note": "master-bootstrap import",
}),
)
.await;
tracing::warn!(
public_key = %pub_pem.lines().nth(1).unwrap_or(""),
"issuer key imported via admin endpoint — restart the service for the new key to take effect"
);
Ok(Json(json!({
"ok": true,
"public_key_pem": pub_pem,
"restart_required": true,
"message": "Issuer key imported. Restart the Keysat service for the new \
key to take effect — until then, in-memory state still holds \
the previous keypair."
})))
}
+306
View File
@@ -0,0 +1,306 @@
//! Machines — individual install records bound to a license.
//!
//! In the single-seat case (`licenses.max_machines = 1`), the first
//! successful `/v1/validate` call locks the fingerprint onto the license
//! and creates a `machines` row. Later validations keep heartbeating that
//! row.
//!
//! In the multi-seat case (`max_machines > 1` or `0` for unlimited),
//! validate auto-activates up to the cap. Beyond the cap, the client gets a
//! `too_many_machines` reject and is expected to call
//! `POST /v1/machines/deactivate` with the fingerprint of an old install to
//! free up a slot, then retry.
//!
//! Explicit activation endpoints (`POST /v1/machines/activate`) are offered
//! for apps that want to prompt the user about seat usage before starting up
//! for the first time. They behave identically to `/v1/validate`'s implicit
//! activation, just without requiring the full key check.
//!
//! Admin endpoints let operators look at who's using what and force-kick a
//! machine off a license.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::crypto;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
// ---------- Public endpoints (client-facing) ----------
#[derive(Debug, Deserialize)]
pub struct ActivateReq {
pub key: String,
pub fingerprint: String,
pub hostname: Option<String>,
pub platform: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ActivateResp {
pub ok: bool,
pub machine_id: Option<String>,
pub active_count: i64,
pub max_machines: i64,
pub reason: Option<String>,
}
pub async fn activate(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<ActivateReq>,
) -> AppResult<Json<ActivateResp>> {
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
let license_id = payload.license_id.to_string();
let license = repo::get_license_by_id(&state.db, &license_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("license {license_id}")))?;
if license.status != "active" {
return Ok(Json(ActivateResp {
ok: false,
machine_id: None,
active_count: 0,
max_machines: license.max_machines,
reason: Some(license.status),
}));
}
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
let fp_hash = crate::hex_sha256(&req.fingerprint);
if let Some(m) =
repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?
{
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
let active = repo::list_active_machines(&state.db, &license_id).await?;
return Ok(Json(ActivateResp {
ok: true,
machine_id: Some(m.id),
active_count: active.len() as i64,
max_machines: license.max_machines,
reason: None,
}));
}
let active = repo::list_active_machines(&state.db, &license_id).await?;
if license.max_machines > 0 && active.len() as i64 >= license.max_machines {
return Ok(Json(ActivateResp {
ok: false,
machine_id: None,
active_count: active.len() as i64,
max_machines: license.max_machines,
reason: Some("too_many_machines".into()),
}));
}
let m = repo::activate_machine(
&state.db,
&license_id,
&req.fingerprint,
&fp_hash,
req.hostname.as_deref(),
req.platform.as_deref(),
client_ip.as_deref(),
)
.await?;
crate::webhooks::dispatch(
&state,
"machine.activated",
&json!({
"license_id": license_id,
"machine_id": m.id,
"fingerprint_hash": fp_hash,
}),
)
.await;
let active = repo::list_active_machines(&state.db, &license_id).await?;
Ok(Json(ActivateResp {
ok: true,
machine_id: Some(m.id),
active_count: active.len() as i64,
max_machines: license.max_machines,
reason: None,
}))
}
#[derive(Debug, Deserialize)]
pub struct HeartbeatReq {
pub key: String,
pub fingerprint: String,
}
pub async fn heartbeat(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<HeartbeatReq>,
) -> AppResult<Json<Value>> {
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
let license_id = payload.license_id.to_string();
// Rate-limit heartbeats per-license to 60/hr.
if !crate::rate_limit::consume(
&state.db,
"heartbeat_license",
&license_id,
/* capacity */ 60.0,
/* refill_per_second */ 60.0 / 3600.0,
)
.await?
{
return Ok(Json(json!({ "ok": false, "reason": "rate_limited" })));
}
let fp_hash = crate::hex_sha256(&req.fingerprint);
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
match repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await? {
Some(m) => {
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
Ok(Json(json!({ "ok": true, "machine_id": m.id })))
}
None => Ok(Json(json!({ "ok": false, "reason": "not_activated" }))),
}
}
#[derive(Debug, Deserialize)]
pub struct DeactivateReq {
pub key: String,
pub fingerprint: String,
#[serde(default)]
pub reason: Option<String>,
}
pub async fn deactivate(
State(state): State<AppState>,
Json(req): Json<DeactivateReq>,
) -> AppResult<Json<Value>> {
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
let license_id = payload.license_id.to_string();
let fp_hash = crate::hex_sha256(&req.fingerprint);
let m = repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?;
let Some(m) = m else {
return Ok(Json(json!({ "ok": false, "reason": "not_found" })));
};
let reason = req
.reason
.unwrap_or_else(|| "client_requested".to_string());
repo::deactivate_machine(&state.db, &m.id, &reason).await?;
crate::webhooks::dispatch(
&state,
"machine.deactivated",
&json!({
"license_id": license_id,
"machine_id": m.id,
"reason": reason,
}),
)
.await;
// Single-seat legacy: also clear licenses.fingerprint so the next client
// can re-bind cleanly.
let license = repo::get_license_by_id(&state.db, &license_id).await?;
if let Some(lic) = license {
if lic.max_machines == 1 {
let _ = sqlx::query("UPDATE licenses SET fingerprint = NULL WHERE id = ?")
.bind(&license_id)
.execute(&state.db)
.await;
}
}
Ok(Json(json!({ "ok": true })))
}
// ---------- Admin endpoints ----------
#[derive(Debug, Deserialize)]
pub struct AdminListQuery {
pub license_id: String,
#[serde(default)]
pub include_inactive: bool,
}
pub async fn admin_list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<AdminListQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let machines = if q.include_inactive {
repo::list_all_machines(&state.db, &q.license_id).await?
} else {
repo::list_active_machines(&state.db, &q.license_id).await?
};
Ok(Json(json!({ "machines": machines })))
}
#[derive(Debug, Deserialize)]
pub struct AdminDeactivateReq {
#[serde(default)]
pub reason: String,
}
pub async fn admin_deactivate(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<AdminDeactivateReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin deactivate".to_string()
} else {
req.reason
};
let m = repo::get_machine_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("machine {id}")))?;
repo::deactivate_machine(&state.db, &id, &reason).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"machine.deactivate",
Some("machine"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "license_id": m.license_id, "reason": reason }),
)
.await;
crate::webhooks::dispatch(
&state,
"machine.deactivated",
&json!({
"license_id": m.license_id,
"machine_id": id,
"reason": reason,
}),
)
.await;
Ok(Json(json!({ "ok": true })))
}
+666
View File
@@ -0,0 +1,666 @@
//! HTTP API surface.
//!
//! Route layout (v1):
//!
//! | Method | Path | Purpose |
//! |--------|----------------------------------------|---------------------------------------------|
//! | GET | `/` | service info + public key |
//! | GET | `/healthz` | health check |
//! | GET | `/thank-you` | post-payment landing (BTCPay redirect tgt) |
//! | GET | `/admin/` | embedded admin web UI (SPA, client-gated) |
//! | GET | `/admin/<path>` | static assets for the embedded admin UI |
//! | GET | `/v1/pubkey` | PEM-encoded Ed25519 public key |
//! | GET | `/v1/products` | list active products |
//! | GET | `/v1/products/:slug` | single product |
//! | POST | `/v1/purchase` | start purchase, returns BTCPay URL |
//! | GET | `/v1/purchase/:invoice_id` | poll purchase status + license if ready |
//! | POST | `/v1/redeem` | redeem a 'free_license' code, no BTCPay |
//! | POST | `/v1/validate` | validate a license key |
//! | POST | `/v1/machines/activate` | explicit seat activation |
//! | POST | `/v1/machines/heartbeat` | seat heartbeat |
//! | POST | `/v1/machines/deactivate` | free a seat (client-initiated) |
//! | POST | `/v1/btcpay/webhook` | BTCPay webhook landing |
//! | Admin endpoints require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY` |
//! | POST | `/v1/admin/products` | create product |
//! | PATCH | `/v1/admin/products/:id/active` | activate / deactivate |
//! | POST | `/v1/admin/licenses` | manually issue license (comp/dev) |
//! | GET | `/v1/admin/licenses` | list licenses by product |
//! | GET | `/v1/admin/licenses/search` | search by email / npub / invoice |
//! | POST | `/v1/admin/licenses/:id/revoke` | revoke a license |
//! | POST | `/v1/admin/licenses/:id/suspend` | suspend (reversible) |
//! | POST | `/v1/admin/licenses/:id/unsuspend` | unsuspend |
//! | POST | `/v1/admin/policies` | create policy (license template) |
//! | GET | `/v1/admin/policies` | list policies for product |
//! | PATCH | `/v1/admin/policies/:id/active` | activate / deactivate policy |
//! | GET | `/v1/admin/machines` | list machines for a license |
//! | POST | `/v1/admin/machines/:id/deactivate` | force-kick a machine |
//! | POST | `/v1/admin/webhook-endpoints` | register webhook subscriber |
//! | GET | `/v1/admin/webhook-endpoints` | list webhook subscribers |
//! | PATCH | `/v1/admin/webhook-endpoints/:id/active` | enable/disable |
//! | DELETE | `/v1/admin/webhook-endpoints/:id` | delete webhook subscriber |
//! | POST | `/v1/admin/discount-codes` | create discount / referral code |
//! | GET | `/v1/admin/discount-codes` | list discount codes |
//! | GET | `/v1/admin/discount-codes/:id` | one code with redemption history |
//! | PATCH | `/v1/admin/discount-codes/:id/active` | enable / disable code |
//! | DELETE | `/v1/admin/discount-codes/:id` | hard-delete (refused if redeemed) |
//! | GET | `/v1/discount-codes/preview` | PUBLIC: preview discount on a product |
//! | GET | `/v1/admin/audit` | list audit log entries |
pub mod admin;
pub mod admin_ui;
pub mod btcpay_authorize;
pub mod discount_codes;
pub mod machines;
pub mod policies;
pub mod products;
pub mod purchase;
pub mod buy_page;
pub mod issuer_key;
pub mod redeem;
pub mod self_license;
pub mod validate;
pub mod webhook;
pub mod webhook_endpoints;
use crate::btcpay::client::BtcpayClient;
use crate::config::Config;
use crate::crypto::keys::ServerKeypair;
use crate::error::{AppError, AppResult};
use axum::{
extract::FromRef,
routing::{get, patch, post},
Json, Router,
};
use serde_json::json;
use sqlx::SqlitePool;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
pub keypair: Arc<ServerKeypair>,
/// Active payment provider (BTCPay today, Zaprite eventually).
/// `None` until the operator completes a connect flow. Stored as
/// `Arc<dyn ...>` so call sites get cheap clones; swapped under a
/// write lock when the operator runs Connect / Disconnect.
pub payment: Arc<RwLock<Option<Arc<dyn crate::payment::PaymentProvider>>>>,
pub config: Arc<Config>,
/// Keysat-licenses-Keysat tier. Read at boot, swapped when the
/// operator activates a fresh license via the admin endpoint.
pub self_tier: Arc<RwLock<crate::license_self::Tier>>,
}
impl AppState {
/// Provider-agnostic accessor. New code should use this; legacy
/// `btcpay_client()` / `btcpay_webhook_secret()` accessors remain
/// for v0.2 compat and will retire as call sites migrate in v0.3.
pub async fn payment_provider(
&self,
) -> AppResult<Arc<dyn crate::payment::PaymentProvider>> {
let guard = self.payment.read().await;
guard
.as_ref()
.cloned()
.ok_or(AppError::BtcpayNotConfigured)
}
/// Compat: returns the BTCPay-specific HTTP client, by clone, when
/// the active provider is BTCPay. Falls back to
/// `BtcpayNotConfigured` either when no provider is connected OR
/// when the active provider isn't BTCPay (so Zaprite-only operators
/// in v0.3 will get a clean error from BTCPay-specific code paths
/// that haven't been migrated yet).
pub async fn btcpay_client(&self) -> AppResult<BtcpayClient> {
let guard = self.payment.read().await;
let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?;
provider
.as_any()
.downcast_ref::<crate::payment::btcpay::BtcpayProvider>()
.map(|p| p.client().clone())
.ok_or(AppError::BtcpayNotConfigured)
}
/// Compat: returns the BTCPay HMAC webhook secret. See
/// `btcpay_client()` for compat-error semantics.
pub async fn btcpay_webhook_secret(&self) -> AppResult<String> {
let guard = self.payment.read().await;
let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?;
provider
.as_any()
.downcast_ref::<crate::payment::btcpay::BtcpayProvider>()
.map(|p| p.webhook_secret().to_string())
.ok_or(AppError::BtcpayNotConfigured)
}
/// Swap the active payment provider. Called by `btcpay_authorize`
/// (and, later, `zaprite_authorize`).
pub async fn set_payment_provider(
&self,
provider: Arc<dyn crate::payment::PaymentProvider>,
) {
let mut guard = self.payment.write().await;
*guard = Some(provider);
}
/// Clear the active payment provider (Disconnect flow).
pub async fn clear_payment_provider(&self) {
let mut guard = self.payment.write().await;
*guard = None;
}
}
impl FromRef<AppState> for SqlitePool {
fn from_ref(app: &AppState) -> Self {
app.db.clone()
}
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/", get(root))
.route("/healthz", get(healthz))
.route("/thank-you", get(thank_you))
// Public buyer-facing purchase page. Server-renders an HTML
// page for a given product slug; the inlined form POSTs to
// /v1/purchase and redirects to BTCPay checkout.
.route("/buy/:slug", get(buy_page::render))
// Admin web UI — embedded into the binary at compile time via
// rust-embed (see api/admin_ui.rs). The HTML page itself is
// public; the SPA gates access client-side using the admin API
// key, which is enforced server-side on every /v1/admin/*
// call.
.route("/admin", get(admin_ui::admin_root_redirect))
.route("/admin/", get(admin_ui::admin_index))
.route("/admin/*path", get(admin_ui::admin_asset))
.route("/v1/pubkey", get(pubkey))
.route("/v1/products", get(products::list))
.route("/v1/products/:slug", get(products::get))
.route("/v1/purchase", post(purchase::start))
.route("/v1/purchase/:invoice_id", get(purchase::status))
.route("/v1/redeem", post(redeem::redeem))
.route("/v1/validate", post(validate::validate))
// Client-facing machine endpoints.
.route("/v1/machines/activate", post(machines::activate))
.route("/v1/machines/heartbeat", post(machines::heartbeat))
.route("/v1/machines/deactivate", post(machines::deactivate))
.route("/v1/btcpay/webhook", post(webhook::handle))
.route(
"/v1/admin/btcpay/connect",
post(btcpay_authorize::start_connect),
)
.route(
"/v1/btcpay/authorize/callback",
post(btcpay_authorize::callback).get(btcpay_authorize::callback_get),
)
.route(
"/v1/admin/btcpay/status",
get(btcpay_authorize::status),
)
.route(
"/v1/admin/btcpay/disconnect",
post(btcpay_authorize::disconnect),
)
.route(
"/v1/admin/btcpay/payment-methods",
get(btcpay_authorize::payment_methods),
)
.route("/v1/admin/products", post(admin::create_product))
.route(
"/v1/admin/products/:id/active",
patch(admin::set_product_active),
)
// Both GET (list) and POST (issue) on the same path — must be chained
// onto a single MethodRouter, because axum's Router::route replaces.
.route(
"/v1/admin/licenses",
get(admin::list_licenses).post(admin::issue_license),
)
.route(
"/v1/admin/licenses/search",
get(admin::search_licenses),
)
.route(
"/v1/admin/licenses/:id/revoke",
post(admin::revoke_license),
)
.route(
"/v1/admin/licenses/:id/suspend",
post(admin::suspend_license),
)
.route(
"/v1/admin/licenses/:id/unsuspend",
post(admin::unsuspend_license),
)
// Policies (license templates).
.route(
"/v1/admin/policies",
get(policies::list).post(policies::create),
)
.route(
"/v1/admin/policies/:id/active",
patch(policies::set_active),
)
.route(
"/v1/admin/policies/:id/tip",
patch(policies::set_tip),
)
.route("/v1/admin/tips", get(policies::list_tips))
// Machines (admin views).
.route("/v1/admin/machines", get(machines::admin_list))
.route(
"/v1/admin/machines/:id/deactivate",
post(machines::admin_deactivate),
)
// Webhook subscribers.
.route(
"/v1/admin/webhook-endpoints",
get(webhook_endpoints::list).post(webhook_endpoints::create),
)
.route(
"/v1/admin/webhook-endpoints/:id/active",
patch(webhook_endpoints::set_active),
)
.route(
"/v1/admin/webhook-endpoints/:id",
axum::routing::delete(webhook_endpoints::delete),
)
// Discount / referral codes.
.route(
"/v1/admin/discount-codes",
get(discount_codes::list).post(discount_codes::create),
)
.route(
"/v1/admin/discount-codes/:id",
get(discount_codes::get_one).delete(discount_codes::delete),
)
.route(
"/v1/admin/discount-codes/:id/active",
patch(discount_codes::set_active),
)
// Public preview — buyer hits this from the buy page when they
// click Apply on a discount code. Returns kind + computed
// discounted price, doesn't consume a redemption slot.
.route(
"/v1/discount-codes/preview",
get(discount_codes::preview),
)
// Audit log.
.route("/v1/admin/audit", get(admin::list_audit))
// Live-mutable settings.
.route(
"/v1/admin/settings/operator-name",
get(admin::get_operator_name).post(admin::set_operator_name),
)
// Keysat self-license (Keysat-licenses-Keysat).
.route(
"/v1/admin/self-license",
get(self_license::status).post(self_license::activate),
)
// Issuer-key import — admin-only, master-bootstrap path. No
// StartOS Action surface; documented in MASTER_KEYPAIR_PROCEDURE.md.
.route("/v1/admin/import-issuer-key", post(issuer_key::import))
.with_state(state)
}
async fn root(
axum::extract::State(state): axum::extract::State<AppState>,
) -> Json<serde_json::Value> {
// Live-read the operator name from the settings table so admin
// updates take effect without a daemon restart. Falls back to the
// env-var-loaded config if the DB row hasn't been set yet (fresh
// installs, or installs that pre-date this feature).
let operator = match crate::db::repo::settings_get(
&state.db,
crate::api::admin::SETTING_OPERATOR_NAME,
)
.await
{
Ok(Some(v)) => Some(v),
_ => state.config.operator_name.clone(),
};
Json(json!({
"service": "keysat",
"version": env!("CARGO_PKG_VERSION"),
"operator": operator,
"public_key_pem": state.keypair.public_key_pem,
"key_algorithm": "ed25519",
"key_format_version": crate::crypto::KEY_VERSION,
}))
}
async fn healthz() -> Json<serde_json::Value> {
Json(json!({ "ok": true }))
}
/// HTML "thank you" landing page that BTCPay redirects buyers to after a
/// settled invoice. Reads `?invoice_id=<id>` from the query string,
/// renders a Keysat-branded polling page that calls
/// /v1/purchase/<invoice_id> every few seconds until the response
/// includes a `license_key`, then renders the license inline in a
/// certificate-style card with a Copy button. Same visual language
/// as the buy page's free-license success state.
async fn thank_you(
axum::extract::State(state): axum::extract::State<AppState>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> axum::response::Html<String> {
let invoice_id = params.get("invoice_id").cloned().unwrap_or_default();
let invoice_id_safe = html_escape(&invoice_id);
let invoice_id_json = serde_json::to_string(&invoice_id).unwrap_or_else(|_| "\"\"".into());
// Live-read operator_name from the settings table; fall back to the
// env-var config; final fallback to a neutral brand name.
let live = crate::db::repo::settings_get(
&state.db,
crate::api::admin::SETTING_OPERATOR_NAME,
)
.await
.ok()
.flatten();
let operator_str = live
.as_deref()
.or(state.config.operator_name.as_deref())
.unwrap_or("Keysat");
let operator = html_escape(operator_str);
let body = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Payment received — {operator}</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {{
--navy-950:#0E1F33; --navy-900:#142A47; --navy-800:#1E3A5F;
--cream-50:#FBF9F2; --cream-100:#F5F1E8; --cream-200:#EDE7D7;
--gold-700:#8A6F3D; --gold-500:#BFA068; --gold-400:#D4B985;
--ink-900:#0E1F33; --ink-700:#2C3E54; --ink-500:#5A6B7F;
--success:#2D7A5F; --success-bg:#E3F0EA;
--danger:#B23A3A; --danger-bg:#F4E0E0;
--border-1:rgba(14,31,51,0.12);
--border-2:rgba(14,31,51,0.20);
--font-display:'Manrope','Helvetica Neue',Arial,sans-serif;
--font-body:'Inter','Helvetica Neue',Arial,sans-serif;
--font-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;
--shadow-md:0 2px 4px rgba(14,31,51,0.06),0 4px 12px rgba(14,31,51,0.06);
}}
*{{box-sizing:border-box}} html,body{{margin:0;padding:0}}
body {{
font-family:var(--font-body); color:var(--ink-900);
background:var(--cream-100);
background-image:
radial-gradient(rgba(14,31,51,0.025) 1px, transparent 1px),
radial-gradient(rgba(138,111,61,0.022) 1px, transparent 1px);
background-size:3px 3px, 7px 7px;
-webkit-font-smoothing:antialiased; min-height:100vh;
}}
.topbar {{
background:rgba(245,241,232,0.85); backdrop-filter:blur(10px);
border-bottom:1px solid var(--border-1); padding:14px 24px;
}}
.topbar .inner {{
max-width:680px; margin:0 auto;
display:flex; align-items:center; gap:12px;
font-family:var(--font-display); font-weight:500; font-size:14px;
letter-spacing:0.28em; text-transform:uppercase; color:var(--navy-900);
}}
.topbar .operator {{
font-family:var(--font-body); font-size:12px;
letter-spacing:0.04em; text-transform:none;
color:var(--ink-500); margin-left:auto;
}}
.wrap {{ max-width:560px; margin:48px auto; padding:0 24px; }}
.eyebrow {{
font-size:11.5px; font-weight:700; letter-spacing:0.18em;
text-transform:uppercase; color:var(--gold-700); margin-bottom:14px;
display:inline-flex; align-items:center; gap:10px;
}}
.eyebrow::before {{ content:''; display:inline-block; width:28px; height:1px; background:var(--gold-500); }}
h1 {{
font-family:var(--font-display); font-weight:500; font-size:38px;
line-height:1.05; letter-spacing:-0.022em; color:var(--navy-950); margin:0 0 14px;
}}
.lede {{ font-size:16px; line-height:1.55; color:var(--ink-700); margin:0 0 28px; }}
.pending-card, .license-success, .error-card {{
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:14px; box-shadow:var(--shadow-md);
padding:32px 32px 28px; position:relative;
}}
.license-success, .pending-card {{
box-shadow:0 0 0 1px var(--gold-500) inset, var(--shadow-md);
}}
.license-success::before, .license-success::after,
.pending-card::before, .pending-card::after {{
content:''; position:absolute; left:14px; right:14px;
height:1px; background:var(--gold-500); opacity:0.5;
}}
.license-success::before, .pending-card::before {{ top:14px; }}
.license-success::after, .pending-card::after {{ bottom:14px; }}
.stamp {{
font-size:10px; font-weight:700; letter-spacing:0.22em;
text-transform:uppercase; color:var(--gold-700);
text-align:center; margin-bottom:16px;
}}
.pending-card h2 {{
font-family:var(--font-display); font-weight:500; font-size:22px;
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em; text-align:center;
}}
.pending-card .sub, .license-success .sub {{
font-size:14px; color:var(--ink-500); text-align:center; margin:0 0 22px;
}}
.spinner {{
width:32px; height:32px; border-radius:50%;
border:3px solid var(--border-1); border-top-color:var(--gold-500);
animation:spin 1s linear infinite;
margin:18px auto 22px;
}}
@keyframes spin {{ to {{ transform:rotate(360deg); }} }}
.status-detail {{
font-family:var(--font-mono); font-size:12.5px;
background:var(--cream-100); border:1px solid var(--border-1);
border-radius:7px; padding:8px 12px;
color:var(--ink-700); text-align:center;
}}
.license-success h2 {{
font-family:var(--font-display); font-weight:500; font-size:22px;
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em; text-align:center;
}}
.field-label {{
font-size:11px; font-weight:600; letter-spacing:0.12em;
text-transform:uppercase; color:var(--ink-500); margin-bottom:6px;
}}
.key-box {{
background:var(--navy-950); color:var(--cream-50);
padding:14px 16px; border-radius:8px;
font-family:var(--font-mono); font-size:12.5px;
word-break:break-all; line-height:1.5;
display:flex; align-items:flex-start; gap:12px;
}}
.key-box .key-text {{ flex:1; }}
.key-box button {{
background:rgba(245,241,232,0.10); color:var(--cream-50);
border:0; padding:6px 10px; border-radius:6px;
font-family:var(--font-body); font-size:11.5px; cursor:pointer;
flex-shrink:0;
}}
.key-box button:hover {{ background:rgba(245,241,232,0.20); }}
.save-note {{
margin-top:14px; font-size:13px; color:var(--ink-700);
background:var(--cream-100); border:1px solid var(--border-1);
border-radius:8px; padding:10px 14px;
}}
.save-note strong {{ color:var(--navy-950); }}
.error-card {{
border-color:rgba(178,58,58,0.3); background:var(--danger-bg);
color:#8a2828; font-size:14px;
}}
.hide {{ display:none !important; }}
footer.kfooter {{
text-align:center; font-size:12px; color:var(--ink-500);
margin-top:48px; padding:18px;
}}
footer.kfooter a {{ color:var(--ink-500); text-decoration:none; }}
footer.kfooter a:hover {{ color:var(--navy-900); }}
</style>
</head>
<body>
<div class="topbar">
<div class="inner">
<span>Keysat</span>
<span class="operator">Sold by {operator}</span>
</div>
</div>
<div class="wrap">
<div class="eyebrow">Payment received</div>
<h1 id="page-title">Issuing your license&hellip;</h1>
<p class="lede" id="page-lede">Your Bitcoin payment was received. We&rsquo;re waiting for it to settle on the network and for the license to be signed. This usually takes under a minute once the next block confirms.</p>
<!-- pending state (default): polling for the license -->
<div class="pending-card" id="pending-card">
<div class="stamp">&mdash; Awaiting confirmation &mdash;</div>
<h2>Hang tight.</h2>
<p class="sub">This page will refresh automatically when your license is ready.</p>
<div class="spinner" aria-hidden="true"></div>
<div class="status-detail" id="status-detail">checking status&hellip;</div>
</div>
<!-- success state: license card -->
<div class="license-success hide" id="license-success" role="region" aria-label="License issued">
<div class="stamp">&mdash; License issued &mdash;</div>
<h2>You&rsquo;re licensed.</h2>
<p class="sub">Your signed license is below. We&rsquo;ll also email a copy.</p>
<div class="field-label">License key</div>
<div class="key-box">
<span class="key-text" id="license-key-text">&hellip;</span>
<button id="license-key-copy">Copy</button>
</div>
<div class="save-note">
<strong>Save this somewhere safe.</strong> The key is signed at issue time and verifies offline against the seller&rsquo;s public key. You don&rsquo;t need to come back here.
</div>
</div>
<!-- error state: invoice not found, or unrecoverable -->
<div class="error-card hide" id="error-card" role="alert">
<div id="error-msg">Something went wrong looking up this purchase.</div>
</div>
</div>
<footer class="kfooter">
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-paid software licensing</span>
</footer>
<script>
(function() {{
const INVOICE_ID = {invoice_id_json};
if (!INVOICE_ID) {{
document.getElementById('pending-card').classList.add('hide');
document.getElementById('error-card').classList.remove('hide');
document.getElementById('error-msg').textContent = 'No invoice id supplied. Looking for your license? Check your email or contact the seller.';
return;
}}
const pendingCard = document.getElementById('pending-card');
const successCard = document.getElementById('license-success');
const errorCard = document.getElementById('error-card');
const statusDetail = document.getElementById('status-detail');
const keyText = document.getElementById('license-key-text');
const errorMsg = document.getElementById('error-msg');
const pageTitle = document.getElementById('page-title');
const pageLede = document.getElementById('page-lede');
// Copy button.
document.getElementById('license-key-copy').addEventListener('click', async function() {{
try {{
await navigator.clipboard.writeText(keyText.textContent);
this.textContent = 'Copied';
setTimeout(() => {{ this.textContent = 'Copy'; }}, 1400);
}} catch (e) {{}}
}});
function showSuccess(licenseKey) {{
pendingCard.classList.add('hide');
errorCard.classList.add('hide');
keyText.textContent = licenseKey;
successCard.classList.remove('hide');
pageTitle.textContent = 'Your license is ready.';
pageLede.textContent = 'Save the key below — it verifies offline against the sellers public key. You can close this tab when youre done.';
}}
function showError(msg) {{
pendingCard.classList.add('hide');
successCard.classList.add('hide');
errorMsg.textContent = msg;
errorCard.classList.remove('hide');
pageTitle.textContent = 'Something went wrong.';
pageLede.textContent = 'See the message below for details.';
}}
let attempt = 0;
const MAX_ATTEMPTS = 240; // 240 * 3s = 12 min total. Most settle inside 1.
async function poll() {{
attempt++;
try {{
const r = await fetch('/v1/purchase/' + encodeURIComponent(INVOICE_ID));
if (r.status === 404) {{
return showError('Invoice not found. The link may have been mistyped.');
}}
if (!r.ok) {{
statusDetail.textContent = 'server returned HTTP ' + r.status + ' (will retry)';
return scheduleNext();
}}
const j = await r.json();
if (j.license_key) {{
return showSuccess(j.license_key);
}}
const status = j.status || 'pending';
statusDetail.textContent = 'invoice status: ' + status + (attempt > 1 ? ' (still polling)' : '');
if (status === 'expired' || status === 'invalid') {{
return showError('Payment was not completed (status: ' + status + '). If you sent funds, contact the seller.');
}}
scheduleNext();
}} catch (err) {{
statusDetail.textContent = 'network error (retrying): ' + (err.message || err);
scheduleNext();
}}
}}
function scheduleNext() {{
if (attempt >= MAX_ATTEMPTS) {{
statusDetail.textContent = 'still waiting — refresh the page or come back later.';
return;
}}
setTimeout(poll, 3000);
}}
poll();
}})();
</script>
</body>
</html>"#
);
axum::response::Html(body)
}
/// Minimal HTML escape for the operator name. Keeps this module dependency-free.
fn html_escape(s: &str) -> String {
s.chars()
.map(|c| match c {
'&' => "&amp;".to_string(),
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
'\'' => "&#39;".to_string(),
_ => c.to_string(),
})
.collect()
}
async fn pubkey(
axum::extract::State(state): axum::extract::State<AppState>,
) -> Json<serde_json::Value> {
Json(json!({
"algorithm": "ed25519",
"public_key_pem": state.keypair.public_key_pem,
}))
}
+270
View File
@@ -0,0 +1,270 @@
//! Policies — reusable license templates.
//!
//! A policy captures "when I issue a license under this shape, what are the
//! defaults?" (duration, grace period, entitlements, machine cap, trial flag,
//! optional price override). Callers to `/v1/admin/licenses` can reference a
//! policy by slug instead of specifying every field.
//!
//! Policies are per-product. The system looks up a "default" policy for a
//! product when a customer buys it through the normal purchase flow — so most
//! products should have at least one policy slugged `default`.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct CreatePolicyReq {
pub product_slug: String,
pub name: String,
pub slug: String,
/// 0 = perpetual.
#[serde(default)]
pub duration_seconds: i64,
#[serde(default)]
pub grace_seconds: i64,
/// 0 = unlimited, 1 = single-seat, n>1 = n-seat.
#[serde(default = "default_max_machines")]
pub max_machines: i64,
#[serde(default)]
pub is_trial: bool,
#[serde(default)]
pub price_sats_override: Option<i64>,
#[serde(default)]
pub entitlements: Vec<String>,
#[serde(default)]
pub metadata: Value,
/// Optional Lightning recipient (e.g. "tip@keysat.xyz") to tip a percentage
/// of each successful issuance to. None = no tipping.
#[serde(default)]
pub tip_recipient: Option<String>,
/// Tip percentage in basis points. 100 = 1%. Capped at 10000 (=100%).
#[serde(default)]
pub tip_pct_bps: i64,
/// Free-form label for the tip recipient (audit/UI).
#[serde(default)]
pub tip_label: Option<String>,
}
fn default_max_machines() -> i64 {
1
}
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreatePolicyReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product_slug)))?;
if req.duration_seconds < 0 {
return Err(AppError::BadRequest("duration_seconds must be >= 0".into()));
}
if req.grace_seconds < 0 {
return Err(AppError::BadRequest("grace_seconds must be >= 0".into()));
}
if req.max_machines < 0 {
return Err(AppError::BadRequest("max_machines must be >= 0".into()));
}
let metadata = if req.metadata.is_null() {
json!({})
} else {
req.metadata
};
if req.tip_pct_bps < 0 || req.tip_pct_bps > 10_000 {
return Err(AppError::BadRequest(
"tip_pct_bps must be between 0 and 10000 (100%)".into(),
));
}
let tip_recipient = req.tip_recipient.as_deref().filter(|s| !s.trim().is_empty());
if tip_recipient.is_some() && req.tip_pct_bps == 0 {
return Err(AppError::BadRequest(
"tip_pct_bps must be > 0 when tip_recipient is set".into(),
));
}
if tip_recipient.is_none() && req.tip_pct_bps > 0 {
return Err(AppError::BadRequest(
"tip_recipient must be set when tip_pct_bps > 0".into(),
));
}
let tip_label = req.tip_label.as_deref().filter(|s| !s.trim().is_empty());
let policy = repo::create_policy(
&state.db,
&product.id,
&req.name,
&req.slug,
req.duration_seconds,
req.grace_seconds,
req.max_machines,
req.is_trial,
req.price_sats_override,
&req.entitlements,
&metadata,
tip_recipient,
req.tip_pct_bps,
tip_label,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.create",
Some("policy"),
Some(&policy.id),
ip.as_deref(),
ua.as_deref(),
&json!({ "product_id": product.id, "slug": policy.slug }),
)
.await;
Ok(Json(json!(policy)))
}
#[derive(Debug, Deserialize)]
pub struct ListPoliciesQuery {
pub product_slug: String,
#[serde(default)]
pub include_inactive: bool,
}
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListPoliciesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let product = repo::get_product_by_slug(&state.db, &q.product_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product_slug)))?;
let rows = repo::list_policies_by_product(&state.db, &product.id, !q.include_inactive).await?;
Ok(Json(json!({ "policies": rows })))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_policy_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.set_active",
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
#[derive(Debug, Deserialize)]
pub struct SetTipReq {
/// Lightning Address (`user@domain`). Pass `null` to disable tipping.
pub tip_recipient: Option<String>,
/// Basis points: 010000. 0 = disabled.
pub tip_pct_bps: i64,
/// Optional free-form label (audit / UI).
#[serde(default)]
pub tip_label: Option<String>,
}
pub async fn set_tip(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetTipReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if req.tip_pct_bps < 0 || req.tip_pct_bps > 10_000 {
return Err(AppError::BadRequest(
"tip_pct_bps must be between 0 and 10000".into(),
));
}
let recipient = req.tip_recipient.as_deref().filter(|s| !s.trim().is_empty());
if recipient.is_some() && req.tip_pct_bps == 0 {
return Err(AppError::BadRequest(
"tip_pct_bps must be > 0 when tip_recipient is set".into(),
));
}
if recipient.is_none() && req.tip_pct_bps > 0 {
return Err(AppError::BadRequest(
"tip_recipient must be set when tip_pct_bps > 0".into(),
));
}
let label = req.tip_label.as_deref().filter(|s| !s.trim().is_empty());
let updated =
repo::set_policy_tip_config(&state.db, &id, recipient, req.tip_pct_bps, label).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.set_tip",
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"tip_recipient": updated.tip_recipient,
"tip_pct_bps": updated.tip_pct_bps,
"tip_label": updated.tip_label,
}),
)
.await;
Ok(Json(json!(updated)))
}
#[derive(Debug, Deserialize)]
pub struct ListTipsQuery {
#[serde(default)]
pub license_id: Option<String>,
#[serde(default)]
pub recipient: Option<String>,
#[serde(default = "default_tip_limit")]
pub limit: i64,
}
fn default_tip_limit() -> i64 {
100
}
pub async fn list_tips(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListTipsQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let entries = repo::list_tip_attempts(
&state.db,
q.license_id.as_deref(),
q.recipient.as_deref(),
q.limit,
)
.await?;
Ok(Json(json!({ "tips": entries })))
}
+25
View File
@@ -0,0 +1,25 @@
//! Public product endpoints.
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, State},
Json,
};
use serde_json::{json, Value};
pub async fn list(State(state): State<AppState>) -> AppResult<Json<Value>> {
let products = repo::list_products(&state.db, true).await?;
Ok(Json(json!({ "products": products })))
}
pub async fn get(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> AppResult<Json<Value>> {
let product = repo::get_product_by_slug(&state.db, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{slug}'")))?;
Ok(Json(json!(product)))
}
+332
View File
@@ -0,0 +1,332 @@
//! Purchase flow:
//! 1. Client POSTs `/v1/purchase` with a product slug.
//! 2. We create a BTCPay invoice, stash a row, return the checkout URL.
//! 3. Client opens the URL, pays. BTCPay hits our webhook (see
//! [`crate::api::webhook`]) which marks the invoice 'settled' and
//! issues a license.
//! 4. Client polls `/v1/purchase/:invoice_id` until `license_key` is
//! present, then stores it locally.
use crate::api::AppState;
use crate::btcpay::client::BtcpayClient;
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct StartPurchaseReq {
/// Product slug to buy.
pub product: String,
/// Optional email for receipt / future contact.
pub buyer_email: Option<String>,
/// Optional free-text note from the buyer.
pub buyer_note: Option<String>,
/// Optional URL the buyer should be returned to after payment.
pub redirect_url: Option<String>,
/// Optional discount / referral code (case-insensitive).
pub code: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct StartPurchaseResp {
pub invoice_id: String, // our internal id
pub btcpay_invoice_id: String, // BTCPay's id (for debugging)
pub checkout_url: String, // URL the user opens to pay
pub amount_sats: i64, // what BTCPay was charged (post-discount)
pub base_price_sats: i64, // product list price (pre-discount)
pub discount_applied_sats: i64, // base - amount_sats; 0 if no code
pub poll_url: String, // where to check status
}
/// Floor for invoiced amount after a discount is applied. Set to 1 sat so
/// 100%-off codes still produce a real BTCPay invoice (and the buyer
/// experiences the purchase flow). 0-sat invoices aren't always supported
/// by BTCPay anyway.
const MIN_INVOICE_SATS: i64 = 1;
pub async fn start(
State(state): State<AppState>,
Json(req): Json<StartPurchaseReq>,
) -> AppResult<Json<StartPurchaseResp>> {
let product = repo::get_product_by_slug(&state.db, &req.product)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product)))?;
if !product.active {
return Err(AppError::BadRequest(format!(
"product '{}' is not available for purchase",
req.product
)));
}
let base_price = product.price_sats;
// Resolve and validate the discount code if one was supplied. The
// ordering here matters: we must atomically reserve a counter slot
// BEFORE we create the BTCPay invoice, so that a code-cap race can't
// result in a buyer holding a discounted live invoice for an
// already-exhausted code.
//
// step A: lookup + eligibility checks (active, expired, applies-to)
// step B: atomically increment used_count (try_reserve_code_slot)
// step C: compute discount, create BTCPay invoice
// step D: persist local invoice
// step E: insert the pending redemption row (record_pending_redemption)
//
// If C, D, or E fail after B succeeded, we call release_code_slot to
// give the slot back.
let (final_price, reservation, discount_applied) = if let Some(raw_code) =
req.code.as_deref().filter(|s| !s.trim().is_empty())
{
let code = repo::get_discount_code_by_code(&state.db, raw_code)
.await?
.ok_or_else(|| AppError::BadRequest("unknown discount code".into()))?;
if !code.active {
return Err(AppError::BadRequest("discount code is disabled".into()));
}
if let Some(exp) = &code.expires_at {
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
return Err(AppError::BadRequest("discount code has expired".into()));
}
}
}
if let Some(pid) = &code.applies_to_product_id {
if pid != &product.id {
return Err(AppError::BadRequest(
"discount code does not apply to this product".into(),
));
}
}
// Note: applies_to_policy_id is informational in v0.1 — the
// policy used at license-issuance time is the product's default.
// Step B: atomic reserve.
repo::try_reserve_code_slot(&state.db, &code.id).await?;
let discount = compute_discount(&code.kind, code.amount, base_price);
let final_price = (base_price - discount).max(MIN_INVOICE_SATS);
(final_price, Some(code), discount)
} else {
(base_price, None, 0)
};
// Pre-allocate an internal invoice id so we can pass it to BTCPay as
// metadata, letting us correlate webhook events back to our row even
// before we've persisted the BTCPay invoice id.
let internal_id = uuid::Uuid::new_v4().to_string();
// If the caller didn't supply a redirect_url, default to our own
// /thank-you page with the invoice id baked in. After payment
// BTCPay sends the buyer's browser there; the page polls
// /v1/purchase/<invoice_id> until the license is issued, then
// renders it. Internal ID (UUID) goes in the URL so the buyer can
// bookmark it / refresh later if they close the tab.
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
state.config.public_base_url, internal_id
);
let redirect_url = req
.redirect_url
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&default_redirect);
let metadata = BtcpayClient::invoice_metadata(&product.id, &internal_id);
let btcpay = match state.btcpay_client().await {
Ok(c) => c,
Err(e) => {
// Release the reserved slot if we have one — BTCPay isn't ready.
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(e);
}
};
// Step C: BTCPay invoice. On failure, release the slot and bail.
let created = match btcpay
.create_invoice(final_price, metadata, Some(redirect_url))
.await
{
Ok(c) => c,
Err(e) => {
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(AppError::Upstream(format!(
"BTCPay invoice create failed: {e}"
)));
}
};
// BTCPay returns a checkout URL using whatever URL we called its
// API at — for us, the internal Docker hostname (fast). Rewrite
// the host to the configured public URL so the buyer actually
// gets a link they can open. Falls through unchanged if no public
// URL is configured (test/dev only).
let checkout_url = match &state.config.btcpay_public_url {
Some(public_base) => {
let rewritten =
crate::payment::btcpay::rewrite_to_public(&created.checkout_link, public_base);
tracing::info!(
original = %created.checkout_link,
rewritten = %rewritten,
public_base = %public_base,
"purchase: checkout URL rewritten for buyer"
);
rewritten
}
None => {
tracing::warn!(
original = %created.checkout_link,
"purchase: checkout URL NOT rewritten — btcpay_public_url is None"
);
created.checkout_link.clone()
}
};
// Step D: persist local invoice. On failure, release the slot.
// Use internal_id we pre-generated (and baked into the BTCPay
// redirect_url) as the local row id so /v1/purchase/<id> and
// /thank-you?invoice_id=<id> all resolve to the same row.
let invoice = match repo::create_invoice(
&state.db,
&internal_id,
&created.id,
&product.id,
final_price,
&checkout_url,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
)
.await
{
Ok(inv) => inv,
Err(e) => {
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(e);
}
};
// Step E: persist the redemption row tying the slot to the invoice.
if let Some(code) = &reservation {
if let Err(e) = repo::record_pending_redemption(
&state.db,
&code.id,
&invoice.id,
discount_applied,
base_price,
final_price,
)
.await
{
// Slot was reserved but we couldn't record the redemption.
// Release the slot and mark the BTCPay invoice as invalid
// locally so we don't accidentally honour it on settle.
tracing::error!(
code = %code.code,
invoice_id = %invoice.id,
error = %e,
"failed to persist pending redemption; releasing slot \
and invalidating local invoice"
);
let _ = repo::release_code_slot(&state.db, &code.id).await;
let _ = repo::update_invoice_status(&state.db, &created.id, "invalid").await;
return Err(e);
}
}
let poll_url = format!("{}/v1/purchase/{}", state.config.public_base_url, invoice.id);
Ok(Json(StartPurchaseResp {
invoice_id: invoice.id,
btcpay_invoice_id: created.id,
checkout_url,
amount_sats: final_price,
base_price_sats: base_price,
discount_applied_sats: discount_applied,
poll_url,
}))
}
/// Apply the discount math. Returns the sats to subtract from `base`.
/// Caller is responsible for clamping the result (and for floor enforcement).
fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 {
match kind {
"percent" => {
// amount is basis points (0..=10000). 5000 == 50%.
// Multiply in i128 to avoid overflow on large sat amounts.
let bps = amount.clamp(0, 10_000) as i128;
let base = base_price_sats as i128;
((base * bps) / 10_000).max(0).min(base) as i64
}
"fixed_sats" => amount.max(0).min(base_price_sats),
_ => 0,
}
}
/// Polling endpoint — returns status; if settled and a license has been
/// issued, returns the signed key string.
pub async fn status(
State(state): State<AppState>,
Path(invoice_id): Path<String>,
) -> AppResult<Json<Value>> {
let invoice = repo::get_invoice_by_id(&state.db, &invoice_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("invoice '{invoice_id}'")))?;
let license = repo::get_license_by_invoice(&state.db, &invoice.id).await?;
let license_key = match &license {
Some(lic) if lic.status == "active" => {
// Re-issue the encoded key deterministically from the stored
// license row. `issued_at` is parseable as RFC3339; we reduce to
// unix seconds. Fingerprint binding isn't done here because the
// key is still unbound at first delivery — it'll be bound the
// first time the app calls /v1/validate or /v1/machines/activate.
let flags = if lic.is_trial { FLAG_TRIAL } else { 0 };
let expires_at = lic
.expires_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.timestamp())
.unwrap_or(0);
let payload = LicensePayload {
version: KEY_VERSION_V2,
flags,
product_id: uuid::Uuid::parse_str(&lic.product_id).map_err(|e| {
AppError::Internal(anyhow::anyhow!("bad stored product_id: {e}"))
})?,
license_id: uuid::Uuid::parse_str(&lic.id).map_err(|e| {
AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}"))
})?,
issued_at: chrono::DateTime::parse_from_rfc3339(&lic.issued_at)
.map(|t| t.timestamp())
.unwrap_or(0),
expires_at,
fingerprint_hash: [0u8; 32],
entitlements: lic.entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
Some(encode_key(&payload, &sig))
}
_ => None,
};
Ok(Json(json!({
"invoice_id": invoice.id,
"status": invoice.status,
"product_id": invoice.product_id,
"amount_sats": invoice.amount_sats,
"license_key": license_key,
"license_id": license.as_ref().map(|l| l.id.clone()),
})))
}
+192
View File
@@ -0,0 +1,192 @@
//! Free-license code redemption — the no-BTCPay path.
//!
//! Flow for `kind = 'free_license'` codes:
//! 1. Buyer hits POST /v1/redeem with `{product, code, buyer_email?, buyer_note?}`.
//! 2. Server validates the code (active, not expired, applies-to, kind == free_license).
//! 3. Server atomically reserves a slot (try_reserve_code_slot).
//! 4. Server synthesizes a settled invoice with amount_sats = 0
//! (so the rest of the data model — license → invoice — stays uniform).
//! 5. Server records the pending redemption row.
//! 6. Server calls the existing `issue_license_for_invoice` path which:
//! - issues the license,
//! - fires `license.issued`,
//! - finalizes the redemption (pending → redeemed),
//! - fires `code.redeemed`.
//! 7. Response includes the signed license_key so the buyer can paste it
//! directly into your app — no polling, no BTCPay.
use crate::api::AppState;
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct RedeemReq {
/// Product slug.
pub product: String,
/// Redeemable code (case-insensitive).
pub code: String,
/// Optional email — recorded on the synthetic invoice and license for
/// admin search and webhook payloads.
pub buyer_email: Option<String>,
/// Optional free-text note (recorded on invoice).
pub buyer_note: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct RedeemResp {
pub license_id: String,
pub license_key: String,
pub invoice_id: String,
pub redemption_id: String,
}
pub async fn redeem(
State(state): State<AppState>,
Json(req): Json<RedeemReq>,
) -> AppResult<Json<RedeemResp>> {
let product = repo::get_product_by_slug(&state.db, &req.product)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product)))?;
if !product.active {
return Err(AppError::BadRequest(format!(
"product '{}' is not available for redemption",
req.product
)));
}
if req.code.trim().is_empty() {
return Err(AppError::BadRequest("code is required".into()));
}
let code = repo::get_discount_code_by_code(&state.db, &req.code)
.await?
.ok_or_else(|| AppError::BadRequest("unknown code".into()))?;
if !code.active {
return Err(AppError::BadRequest("code is disabled".into()));
}
if code.kind != "free_license" {
return Err(AppError::BadRequest(
"this code requires payment — use the standard purchase flow with the code applied".into(),
));
}
if let Some(exp) = &code.expires_at {
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
return Err(AppError::BadRequest("code has expired".into()));
}
}
}
if let Some(pid) = &code.applies_to_product_id {
if pid != &product.id {
return Err(AppError::BadRequest(
"code does not apply to this product".into(),
));
}
}
// Atomic reserve. If reserved succeeds and any subsequent step fails,
// we release the slot so a freed slot becomes available again.
repo::try_reserve_code_slot(&state.db, &code.id).await?;
// Synthesize a settled, zero-amount invoice. Errors release the slot.
let invoice = match repo::create_free_invoice(
&state.db,
&product.id,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
)
.await
{
Ok(inv) => inv,
Err(e) => {
let _ = repo::release_code_slot(&state.db, &code.id).await;
return Err(e);
}
};
// Record the pending redemption row tying the slot to this invoice.
if let Err(e) = repo::record_pending_redemption(
&state.db,
&code.id,
&invoice.id,
0, // discount_applied (whole price is "free")
0, // base_price_sats (free)
0, // final_price_sats
)
.await
{
let _ = repo::release_code_slot(&state.db, &code.id).await;
return Err(e);
}
// Issue the license. This also finalizes the redemption (pending →
// redeemed) and fires both `license.issued` and `code.redeemed`
// outbound webhooks.
let license_id = match crate::api::webhook::issue_license_for_invoice(&state, &invoice).await {
Ok(id) => id,
Err(e) => {
// The invoice + redemption are persisted but the license
// failed. Cancel the redemption so the slot is released and
// log loudly.
tracing::error!(
code = %code.code,
invoice_id = %invoice.id,
error = %e,
"free redemption: license issuance failed after invoice + redemption \
were persisted"
);
if let Ok(Some(red)) =
repo::get_pending_redemption_by_invoice(&state.db, &invoice.id).await
{
let _ = repo::cancel_redemption(&state.db, &red.id).await;
}
return Err(e);
}
};
// Re-derive the signed license key so we can return it to the buyer
// directly. Mirrors the math in `purchase::status`.
let license = repo::get_license_by_invoice(&state.db, &invoice.id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("license vanished after issue")))?;
let flags = if license.is_trial { FLAG_TRIAL } else { 0 };
let expires_at_unix = license
.expires_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.with_timezone(&chrono::Utc).timestamp())
.unwrap_or(0);
let payload = LicensePayload {
version: KEY_VERSION_V2,
flags,
product_id: uuid::Uuid::parse_str(&license.product_id)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored product_id: {e}")))?,
license_id: uuid::Uuid::parse_str(&license.id)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}")))?,
issued_at: chrono::DateTime::parse_from_rfc3339(&license.issued_at)
.map(|t| t.timestamp())
.unwrap_or(0),
expires_at: expires_at_unix,
fingerprint_hash: [0u8; 32],
entitlements: license.entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
let license_key = encode_key(&payload, &sig);
// The redemption row was finalized inside issue_license_for_invoice;
// re-fetch to surface its id in the response.
let redemption_id = repo::list_redemptions_by_code(&state.db, &code.id)
.await
.ok()
.and_then(|rows| rows.into_iter().find(|r| r.invoice_id == invoice.id).map(|r| r.id))
.unwrap_or_default();
Ok(Json(RedeemResp {
license_id,
license_key,
invoice_id: invoice.id,
redemption_id,
}))
}
+172
View File
@@ -0,0 +1,172 @@
//! Admin endpoints for managing the daemon's own self-license
//! (Keysat-licenses-Keysat).
//!
//! - `GET /v1/admin/self-license` — current tier (licensed / unlicensed)
//! - `POST /v1/admin/self-license` — activate a new license. Validates
//! against the embedded master pubkey, writes the file to
//! `SELF_LICENSE_PATH`, and swaps the runtime tier in app state.
//!
//! These run *only* when authenticated by the admin API key — same gate
//! as every other `/v1/admin/*` route.
use crate::api::AppState;
use crate::error::AppResult;
use crate::license_self::{self, Tier};
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
#[serde(tag = "tier", rename_all = "snake_case")]
pub enum TierStatus {
Unlicensed {
reason: String,
mode: &'static str,
},
Licensed {
license_id: String,
product_id: String,
/// Unix seconds; 0 means perpetual.
expires_at: i64,
entitlements: Vec<String>,
mode: &'static str,
},
}
fn tier_to_status(tier: &Tier) -> TierStatus {
let mode = match license_self::mode() {
license_self::Mode::Permissive => "permissive",
license_self::Mode::Enforce => "enforce",
};
match tier {
Tier::Unlicensed { reason } => TierStatus::Unlicensed {
reason: reason.clone(),
mode,
},
Tier::Licensed {
license_id,
product_id,
expires_at,
entitlements,
} => TierStatus::Licensed {
license_id: license_id.to_string(),
product_id: product_id.to_string(),
expires_at: *expires_at,
entitlements: entitlements.clone(),
mode,
},
}
}
pub async fn status(State(state): State<AppState>) -> Json<TierStatus> {
let tier = state.self_tier.read().await.clone();
Json(tier_to_status(&tier))
}
#[derive(Deserialize)]
pub struct ActivateBody {
pub license_key: String,
}
#[derive(Serialize)]
pub struct ActivateResponse {
pub ok: bool,
pub tier: TierStatus,
pub message: String,
}
pub async fn activate(
State(state): State<AppState>,
Json(body): Json<ActivateBody>,
) -> AppResult<impl IntoResponse> {
let key = body.license_key.trim().to_string();
if key.is_empty() {
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "license_key is required"
})),
)
.into_response());
}
// Verify against the embedded master pubkey before persisting.
let new_tier = match license_self::verify_license(&key) {
Ok(t) => t,
Err(e) => {
tracing::warn!("self-license activation rejected: {e:#}");
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "license_invalid",
"detail": format!("{e:#}"),
})),
)
.into_response());
}
};
// Persist to /data/keysat-license.txt.
if let Err(e) = license_self::write_license_file(&key) {
tracing::error!("self-license file write failed: {e:#}");
return Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "write_failed",
"detail": format!("{e:#}"),
})),
)
.into_response());
}
// Swap the runtime tier.
{
let mut guard = state.self_tier.write().await;
*guard = new_tier.clone();
}
let status_resp = tier_to_status(&new_tier);
let summary = match &status_resp {
TierStatus::Licensed {
license_id,
expires_at,
entitlements,
..
} => {
let exp = if *expires_at == 0 {
"perpetual".to_string()
} else {
format!("expires unix={}", expires_at)
};
let ents = if entitlements.is_empty() {
"(none)".to_string()
} else {
entitlements.join(",")
};
format!(
"License {} verified — {}, entitlements={}.",
license_id, exp, ents
)
}
TierStatus::Unlicensed { .. } => {
// Should be unreachable; verify_license never returns Unlicensed.
"License processed.".to_string()
}
};
tracing::info!("self-license activated: {summary}");
Ok((
StatusCode::OK,
Json(ActivateResponse {
ok: true,
tier: status_resp,
message: summary,
}),
)
.into_response())
}
+451
View File
@@ -0,0 +1,451 @@
//! The single most-hit endpoint: validate a license key.
//!
//! Clients — typically another piece of software starting up — call this
//! with their key and (optionally) the `product_slug` they expect the key
//! to cover and a `fingerprint` identifying the machine/installation.
//!
//! Response shape (HTTP always 200; `ok` + `reason` machine-readable):
//!
//! ```json
//! { "ok": true, "license_id": "...", "product_id": "...", "entitlements": ["pro"], "status": "active" }
//! { "ok": false, "reason": "expired", "grace_until": "..." }
//! ```
//!
//! Machine cap handling:
//!
//! When a license allows more than one concurrent machine (`max_machines != 1`),
//! validate will auto-activate up to the cap. Beyond the cap, the call is
//! rejected with `too_many_machines` — the client is expected to either
//! prompt the user to deactivate another machine or to call
//! `POST /v1/machines/deactivate` first. `max_machines == 0` means unlimited.
use crate::api::AppState;
use crate::crypto::{self, hash_fingerprint};
use crate::db::repo;
use crate::error::AppResult;
use axum::{
extract::State,
http::{header, HeaderMap},
Json,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct ValidateReq {
pub key: String,
/// Optional: the product slug the caller expects this key to cover.
/// Rejects keys issued for a different product even if valid.
pub product_slug: Option<String>,
/// Optional: raw machine fingerprint. First successful validation binds
/// this to the license row (if not already set); later validations
/// succeed only if it matches.
pub fingerprint: Option<String>,
/// Optional client-supplied hostname for machine records.
pub hostname: Option<String>,
/// Optional client-supplied platform descriptor.
pub platform: Option<String>,
}
#[derive(Debug, Serialize, Default)]
pub struct ValidateResp {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product_slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issued_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grace_until: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_grace_period: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_trial: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
pub entitlements: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub machine_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_machines: Option<i64>,
}
fn reject(reason: &str) -> ValidateResp {
ValidateResp {
ok: false,
reason: Some(reason.to_string()),
..Default::default()
}
}
pub async fn validate(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<ValidateReq>,
) -> AppResult<Json<ValidateResp>> {
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Rate limit by client IP if available, else by license key prefix as a
// last-ditch bucket key. Cap at 60 req / minute / bucket.
let bucket_key = client_ip.clone().unwrap_or_else(|| {
req.key
.chars()
.take(24)
.collect::<String>()
});
if !crate::rate_limit::consume(
&state.db,
"validate_ip",
&bucket_key,
/* capacity */ 60.0,
/* refill_per_second */ 1.0,
)
.await?
{
return Ok(Json(reject("rate_limited")));
}
// Step 1: parse & verify signature offline-style, using the server's own
// verifying key (same key the SDK will ship).
let (payload, signature, signed_bytes) = match crypto::parse_key(&req.key) {
Ok(ok) => ok,
Err(e) => {
repo::log_validation(
&state.db,
None,
None,
req.fingerprint.as_deref(),
"bad_format",
client_ip.as_deref(),
user_agent.as_deref(),
None,
Some(&e.to_string()),
)
.await
.ok();
tracing::debug!(error = %e, "rejected malformed key");
return Ok(Json(reject("bad_format")));
}
};
if crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature).is_err() {
repo::log_validation(
&state.db,
Some(&payload.license_id.to_string()),
Some(&payload.product_id.to_string()),
req.fingerprint.as_deref(),
"bad_signature",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("bad_signature")));
}
let license_id = payload.license_id.to_string();
let product_id = payload.product_id.to_string();
// Step 2: look up the license row.
let license = match repo::get_license_by_id(&state.db, &license_id).await? {
Some(l) => l,
None => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"not_found",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("not_found")));
}
};
// Step 3: status checks — authoritative server-side.
match license.status.as_str() {
"active" => {}
"revoked" => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"revoked",
client_ip.as_deref(),
user_agent.as_deref(),
None,
license.revocation_reason.as_deref(),
)
.await
.ok();
return Ok(Json(reject("revoked")));
}
"suspended" => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"suspended",
client_ip.as_deref(),
user_agent.as_deref(),
None,
license.suspension_reason.as_deref(),
)
.await
.ok();
return Ok(Json(reject("suspended")));
}
other => {
tracing::warn!(status = other, license_id, "unknown license status");
return Ok(Json(reject("invalid_state")));
}
}
// Step 4: product match (optional).
let product = repo::get_product_by_id(&state.db, &license.product_id).await?;
if let (Some(expected_slug), Some(p)) = (&req.product_slug, &product) {
if &p.slug != expected_slug {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"product_mismatch",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("product_mismatch")));
}
}
// Step 5: expiry + grace.
let now = Utc::now();
let mut in_grace_period = false;
let mut grace_until: Option<String> = None;
if let Some(exp_str) = &license.expires_at {
if let Ok(exp_dt) = DateTime::parse_from_rfc3339(exp_str) {
let exp_utc = exp_dt.with_timezone(&Utc);
let grace_cutoff = exp_utc + chrono::Duration::seconds(license.grace_seconds);
if now >= grace_cutoff {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"expired",
client_ip.as_deref(),
user_agent.as_deref(),
None,
Some(&format!("expired at {exp_str}")),
)
.await
.ok();
return Ok(Json(ValidateResp {
ok: false,
reason: Some("expired".into()),
license_id: Some(license_id),
product_id: Some(product_id),
expires_at: Some(exp_str.clone()),
..Default::default()
}));
} else if now >= exp_utc {
in_grace_period = true;
grace_until = Some(grace_cutoff.to_rfc3339());
}
}
}
// Step 6: fingerprint + machine binding.
// - Single-seat (max_machines == 1): preserve legacy column-based TOFU
// on `licenses.fingerprint` for backwards compatibility, AND also
// write/update a `machines` row so admins see a consistent view.
// - Multi-seat: look up / auto-activate in the machines table, enforce
// the cap.
let mut machine_id: Option<String> = None;
if let Some(fp) = req.fingerprint.as_deref() {
let fp_hash = crate::hex_sha256(fp);
if license.max_machines == 1 {
match &license.fingerprint {
Some(stored) if stored != fp => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
Some(fp),
"fingerprint_mismatch",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("fingerprint_mismatch")));
}
Some(_) => {
// Already bound and matches — touch heartbeat on any machine row.
if let Some(m) =
repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?
{
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
machine_id = Some(m.id);
}
}
None => {
repo::bind_fingerprint_if_unset(&state.db, &license_id, fp).await?;
let m = repo::activate_machine(
&state.db,
&license_id,
fp,
&fp_hash,
req.hostname.as_deref(),
req.platform.as_deref(),
client_ip.as_deref(),
)
.await?;
crate::webhooks::dispatch(
&state,
"machine.activated",
&serde_json::json!({
"license_id": license_id,
"machine_id": m.id,
"fingerprint_hash": fp_hash,
}),
)
.await;
machine_id = Some(m.id);
}
}
} else {
// Multi-seat: consult machines table.
match repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await? {
Some(m) => {
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
machine_id = Some(m.id);
}
None => {
// Count existing active machines. max_machines = 0 means unlimited.
let active = repo::list_active_machines(&state.db, &license_id).await?;
if license.max_machines > 0 && active.len() as i64 >= license.max_machines {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
Some(fp),
"too_many_machines",
client_ip.as_deref(),
user_agent.as_deref(),
None,
Some(&format!(
"cap {} already reached",
license.max_machines
)),
)
.await
.ok();
return Ok(Json(ValidateResp {
ok: false,
reason: Some("too_many_machines".into()),
license_id: Some(license_id),
product_id: Some(product_id),
max_machines: Some(license.max_machines),
..Default::default()
}));
}
let m = repo::activate_machine(
&state.db,
&license_id,
fp,
&fp_hash,
req.hostname.as_deref(),
req.platform.as_deref(),
client_ip.as_deref(),
)
.await?;
crate::webhooks::dispatch(
&state,
"machine.activated",
&serde_json::json!({
"license_id": license_id,
"machine_id": m.id,
"fingerprint_hash": fp_hash,
}),
)
.await;
machine_id = Some(m.id);
}
}
}
// If the signed payload is itself fingerprint-bound, enforce hash
// match against the signed blob (an extra belt-and-braces check).
if payload.is_fingerprint_bound() && payload.fingerprint_hash != hash_fingerprint(fp) {
return Ok(Json(reject("fingerprint_mismatch")));
}
}
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"ok",
client_ip.as_deref(),
user_agent.as_deref(),
machine_id.as_deref(),
if in_grace_period {
Some("in_grace_period")
} else {
None
},
)
.await
.ok();
Ok(Json(ValidateResp {
ok: true,
reason: None,
license_id: Some(license_id),
product_id: Some(product_id),
product_slug: product.map(|p| p.slug),
issued_at: Some(license.issued_at),
expires_at: license.expires_at,
grace_until,
in_grace_period: if in_grace_period { Some(true) } else { None },
is_trial: if license.is_trial { Some(true) } else { None },
entitlements: license.entitlements,
status: Some(license.status),
machine_id,
max_machines: Some(license.max_machines),
}))
}
+305
View File
@@ -0,0 +1,305 @@
//! Payment-provider webhook landing endpoint.
//!
//! Generic over the active `PaymentProvider` (BTCPay today; Zaprite in
//! v0.3). The flow:
//!
//! 1. The provider POSTs an invoice status event here. We hand the raw
//! bytes + headers to the active provider's `validate_webhook` so it
//! can apply its own signature scheme before we trust the body.
//! 2. On `InvoiceSettled`, we mark the invoice settled AND issue a
//! license row (if one doesn't already exist for this invoice —
//! webhooks can be retried). Idempotency is critical.
//! 3. On other events (expired / invalid / refunded), we update status
//! and (for refunds in v0.3) revoke the license.
//!
//! We do **not** sign and return the license key here — the key is
//! lazily re-derived from the stored license row when the buyer polls
//! `/v1/purchase/:invoice_id`. This keeps webhook handling fast and
//! means a dropped webhook response doesn't lose a key.
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use crate::payment::ProviderWebhookEvent;
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
};
use chrono::Utc;
pub async fn handle(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<StatusCode> {
// Active provider validates its own webhooks (each provider has a
// different signature scheme — BTCPay's HMAC-SHA256 in BTCPay-Sig,
// Zaprite's TBD). On any verification failure we 401.
let provider = state.payment_provider().await?;
let event = provider
.validate_webhook(&headers, &body)
.map_err(|e| AppError::Unauthorized.tap_log(format!("webhook validation: {e:#}")))?;
let provider_invoice_id = match event.provider_invoice_id() {
Some(id) => id.to_string(),
None => {
tracing::info!("webhook event without an invoice id; acking");
return Ok(StatusCode::OK);
}
};
let new_status = match &event {
ProviderWebhookEvent::InvoiceSettled { .. } => Some("settled"),
ProviderWebhookEvent::InvoiceExpired { .. } => Some("expired"),
ProviderWebhookEvent::InvoiceInvalid { .. } => Some("invalid"),
// Refunds are a v0.3 surface; for now we treat them as a noop
// and just ack so the provider stops retrying. Once the
// license-revoke-on-refund flow ships, this branch flips to
// doing the revoke + audit-entry work.
ProviderWebhookEvent::InvoiceRefunded { .. } => {
tracing::info!(
provider_invoice_id = %provider_invoice_id,
"refund webhook received; revoke-on-refund flow lands in v0.3"
);
return Ok(StatusCode::OK);
}
ProviderWebhookEvent::Other { kind, .. } => {
tracing::info!(
event_type = %kind,
provider_invoice_id = %provider_invoice_id,
"ignoring non-actionable webhook event"
);
return Ok(StatusCode::OK);
}
};
let new_status = match new_status {
Some(s) => s,
None => return Ok(StatusCode::OK),
};
tracing::info!(
provider = provider.kind().as_str(),
new_status,
provider_invoice_id = %provider_invoice_id,
"webhook event applied"
);
// Persist status.
repo::update_invoice_status(&state.db, &provider_invoice_id, new_status).await?;
// If the invoice is going to a non-success terminal state, free any
// discount-code slot that was reserved for it. We need the internal
// invoice id (not the provider one) to look up the redemption.
if matches!(new_status, "expired" | "invalid") {
if let Ok(Some(inv)) =
repo::get_invoice_by_btcpay_id(&state.db, &provider_invoice_id).await
{
if let Ok(Some(redemption)) =
repo::get_pending_redemption_by_invoice(&state.db, &inv.id).await
{
if let Err(e) = repo::cancel_redemption(&state.db, &redemption.id).await {
tracing::warn!(
redemption_id = %redemption.id,
error = %e,
"failed to cancel redemption on terminal invoice; counter slot may leak"
);
}
}
}
}
if new_status != "settled" {
return Ok(StatusCode::OK);
}
// Find the invoice and issue a license if not already issued.
let invoice = repo::get_invoice_by_btcpay_id(&state.db, &provider_invoice_id).await?;
let Some(invoice) = invoice else {
tracing::warn!(
provider_invoice_id = %provider_invoice_id,
"settled invoice not found in local DB; ignoring"
);
return Ok(StatusCode::OK);
};
// Idempotency: if a license already exists for this invoice, do nothing.
if repo::get_license_by_invoice(&state.db, &invoice.id)
.await?
.is_some()
{
return Ok(StatusCode::OK);
}
let _license_id = issue_license_for_invoice(&state, &invoice).await?;
Ok(StatusCode::OK)
}
/// Shared issuance path — used by both the webhook handler and the reconcile
/// loop. Pulls the invoice's associated policy (if the product has a default
/// one) and materializes a license row with the right expiry / entitlements.
pub async fn issue_license_for_invoice(
state: &AppState,
invoice: &crate::models::Invoice,
) -> AppResult<String> {
// Pick the "default" policy for the product: the first active policy
// whose slug is "default" if present, else the first active policy, else
// none (perpetual, no entitlements, max_machines=1).
let policies = repo::list_policies_by_product(&state.db, &invoice.product_id, true).await?;
let policy = policies
.iter()
.find(|p| p.slug == "default")
.or_else(|| policies.first())
.cloned();
let now = Utc::now();
let issued_at = now.to_rfc3339();
let duration_seconds = policy.as_ref().map(|p| p.duration_seconds).unwrap_or(0);
let expires_at = if duration_seconds == 0 {
None
} else {
Some((now + chrono::Duration::seconds(duration_seconds)).to_rfc3339())
};
let grace_seconds = policy.as_ref().map(|p| p.grace_seconds).unwrap_or(0);
let max_machines = policy.as_ref().map(|p| p.max_machines).unwrap_or(1);
let is_trial = policy.as_ref().map(|p| p.is_trial).unwrap_or(false);
let entitlements = policy
.as_ref()
.map(|p| p.entitlements.clone())
.unwrap_or_default();
let license_id = uuid::Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&license_id,
&invoice.product_id,
Some(&invoice.id),
&issued_at,
&serde_json::json!({
"source": "purchase",
"btcpay_invoice_id": invoice.btcpay_invoice_id,
}),
policy.as_ref().map(|p| p.id.as_str()),
expires_at.as_deref(),
grace_seconds,
max_machines,
&entitlements,
is_trial,
invoice.buyer_email.as_deref(),
None,
)
.await?;
tracing::info!(
license_id = %license_id,
invoice_id = %invoice.id,
policy_id = ?policy.as_ref().map(|p| &p.id),
"license issued for settled invoice"
);
// Fire-and-forget Lightning tip to the policy's configured recipient,
// if any. This never blocks issuance: errors are logged + audited inside
// the spawned task. Skipped silently when the policy has no tip config.
if let Some(p) = policy.as_ref() {
if p.tip_recipient.is_some() && p.tip_pct_bps > 0 {
crate::tipping::spawn_tip(
state.clone(),
license_id.clone(),
p.clone(),
invoice.amount_sats,
);
}
}
crate::webhooks::dispatch(
state,
"license.issued",
&serde_json::json!({
"license_id": license_id,
"product_id": invoice.product_id,
"invoice_id": invoice.id,
"policy_id": policy.as_ref().map(|p| &p.id),
"is_trial": is_trial,
"expires_at": expires_at,
"entitlements": entitlements,
"source": "purchase",
}),
)
.await;
// If this invoice used a discount code, finalize the redemption row
// (transition pending → redeemed, attach license_id) and fire a
// `code.redeemed` webhook. Done here (rather than in the webhook
// handler) so both the webhook path and the reconciler-recovered
// path produce identical effects.
if let Some(redemption) =
repo::get_pending_redemption_by_invoice(&state.db, &invoice.id).await?
{
if let Err(e) =
repo::mark_redemption_redeemed(&state.db, &redemption.id, &license_id).await
{
tracing::warn!(
redemption_id = %redemption.id,
license_id = %license_id,
error = %e,
"failed to mark redemption as redeemed; continuing"
);
}
let code_payload = match repo::get_discount_code_by_id(&state.db, &redemption.code_id).await
{
Ok(Some(code)) => Some(code),
_ => None,
};
let _ = repo::insert_audit(
&state.db,
"system",
None,
"code.redeemed",
Some("discount_code"),
Some(&redemption.code_id),
None,
None,
&serde_json::json!({
"redemption_id": redemption.id,
"invoice_id": invoice.id,
"license_id": license_id,
"discount_applied_sats": redemption.discount_applied_sats,
"base_price_sats": redemption.base_price_sats,
"final_price_sats": redemption.final_price_sats,
}),
)
.await;
crate::webhooks::dispatch(
state,
"code.redeemed",
&serde_json::json!({
"redemption_id": redemption.id,
"code_id": redemption.code_id,
"code": code_payload.as_ref().map(|c| c.code.clone()),
"license_id": license_id,
"product_id": invoice.product_id,
"invoice_id": invoice.id,
"discount_applied_sats": redemption.discount_applied_sats,
"base_price_sats": redemption.base_price_sats,
"final_price_sats": redemption.final_price_sats,
}),
)
.await;
}
Ok(license_id)
}
// Small helper to attach a log line to an error conversion.
trait TapLog {
fn tap_log(self, msg: String) -> Self;
}
impl TapLog for AppError {
fn tap_log(self, msg: String) -> Self {
tracing::warn!("{msg}");
self
}
}
@@ -0,0 +1,154 @@
//! Admin CRUD for webhook endpoints.
//!
//! Operators register one or more URLs that will receive signed JSON
//! notifications of interesting events (`license.issued`, `license.revoked`,
//! `machine.activated`, etc.). Each endpoint has its own HMAC-SHA256 secret;
//! the delivery worker in [`crate::webhooks`] signs bodies with it.
//!
//! The secret is only returned to the operator in plaintext on create — once
//! they've stored it somewhere safe, later reads return the secret masked.
//! (If they lose it, they can rotate by deleting + recreating the endpoint.)
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::AppResult;
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use rand::RngCore;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct CreateEndpointReq {
pub url: String,
/// Event types this endpoint is interested in. Use `["*"]` to receive all
/// events. Examples: `license.issued`, `license.revoked`,
/// `license.suspended`, `machine.activated`, `machine.deactivated`,
/// `invoice.settled`.
#[serde(default = "default_event_types")]
pub event_types: Vec<String>,
#[serde(default)]
pub description: String,
/// Optional explicit secret (hex, 32+ bytes). If omitted, the server
/// generates a fresh 32-byte secret and returns it in the response.
#[serde(default)]
pub secret: Option<String>,
}
fn default_event_types() -> Vec<String> {
vec!["*".to_string()]
}
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateEndpointReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let secret = req.secret.unwrap_or_else(generate_secret);
let ep = repo::create_webhook_endpoint(
&state.db,
&req.url,
&secret,
&req.event_types,
&req.description,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"webhook_endpoint.create",
Some("webhook_endpoint"),
Some(&ep.id),
ip.as_deref(),
ua.as_deref(),
&json!({
"url": ep.url,
"event_types": ep.event_types,
}),
)
.await;
// Return the full endpoint (including the plaintext secret) on create —
// this is the only chance the operator gets to see it.
Ok(Json(json!(ep)))
}
fn generate_secret() -> String {
let mut raw = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw);
hex::encode(raw)
}
#[derive(Debug, Deserialize)]
pub struct ListEndpointsQuery {
#[serde(default)]
pub include_secret: bool,
}
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListEndpointsQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let rows = repo::list_webhook_endpoints(&state.db, q.include_secret).await?;
Ok(Json(json!({ "endpoints": rows })))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_webhook_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"webhook_endpoint.set_active",
Some("webhook_endpoint"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
pub async fn delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::delete_webhook_endpoint(&state.db, &id).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"webhook_endpoint.delete",
Some("webhook_endpoint"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
Ok(Json(json!({ "ok": true })))
}