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