Files
keysat/licensing-service/src/api/admin.rs
T
Grant 68dfe7f6fc Product entitlements catalog (Phase 1: schema + admin + buy page)
Closes the request to make entitlements first-class on products
instead of free-text strings on policies. Operators declare the
closed list of entitlements a product offers — slug + display name
+ optional description — and policies pick from that list with a
click-to-toggle bubble UI. Buy page renders human-readable names
("AI summaries") with descriptions as tooltips, never the raw slug
("ai_summaries").

Schema (migration 0014):
- products.entitlements_catalog_json: nullable JSON column shaped
  as [{slug, name, description}, ...]
- Auto-backfill on upgrade: for each existing product, derive a
  catalog from the union of its policies' entitlement slugs, with
  name = slug.replace('_', ' ') and empty description. Operators
  can refine afterward.
- Products with no policy entitlements stay NULL (legacy
  free-text mode preserved).

Server:
- Product struct gains entitlements_catalog: Option<Vec<EntitlementDef>>
- repo::set_product_entitlements_catalog (validates lowercase ASCII
  slugs, uniqueness, defaults name to slug if empty)
- Product create/update API accept entitlements_catalog;
  update uses double-Option PATCH shape so operators can clear
- Closed-list validation: when product has a non-empty catalog,
  policy create + update reject any entitlement slug not in the
  catalog with a clear error pointing at the right path
- /v1/products/<slug>/policies surfaces entitlements_catalog
  in the product object so SDK consumers can render display
  names client-side
- Buy page renders entitlement display names + description tooltips
  on tier cards (falls back to raw slug for legacy entries that
  predate the catalog)

Admin UI:
- New catalogEditor() helper (repeating slug/name/description rows
  with add/remove buttons) embedded in product create + edit forms
- New entitlementBubblePicker() helper (click-to-toggle pill chips
  showing display name with description tooltip)
- Policy create form: entitlements input swaps based on the chosen
  product's catalog — bubble picker when catalog has entries,
  legacy textarea otherwise. Rebuilds when operator changes
  product.
- Policy edit modal: same bubble-picker-or-textarea swap, scoped
  to the policy's product
- Policy list table: entitlement column shows display names
  (resolved against the product's catalog) instead of slugs

Migration regression test verifies:
- Backfill correctly unions entitlements across all of a product's
  policies, deduplicates, applies name = slug-with-underscores-as-
  spaces transformation
- Products with no policy entitlements get NULL (not [])
- Manually-set catalog values round-trip
- Schema is otherwise FK-clean post-migration

Test count: 78 (was 77; +1 for migration_0014_backfills_*).

Phase 2 (SDK updates + integration doc + side-by-side card-grid
policy authoring UI) ships in follow-up commits before v0.2.0:8.
2026-05-10 07:55:14 -05:00

1178 lines
40 KiB
Rust

//! 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.
///
/// Cookie-based session authentication is layered on top of this via the
/// `session_to_bearer_layer` axum middleware (see `crate::api::session_layer`):
/// when the SPA presents a valid `keysat_session` cookie, that middleware
/// injects an `Authorization: Bearer <api_key>` header on the way in, so
/// `require_admin` keeps working unchanged. The audit log limitation is
/// that all cookie-authenticated calls show the API key's sha256 as the
/// actor — IP / user-agent on the same row distinguish sessions in
/// practice. A v0.2 follow-up adds proper per-session actor identity.
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,
/// Legacy SAT-only price. Optional now; if `price_currency` +
/// `price_value` are supplied, they take precedence. Old SDK
/// callers and the existing admin UI keep using this field
/// without changes.
#[serde(default)]
pub price_sats: Option<i64>,
/// New canonical currency. 'SAT' (default), 'USD', or 'EUR'.
/// 'BTC' is intentionally not yet a separate currency code —
/// pricing in BTC is just SAT pricing with a different display.
/// Future v0.3+ may add it as a display alias.
#[serde(default)]
pub price_currency: Option<String>,
/// Price in the smallest indivisible unit of `price_currency`:
/// sats for SAT, cents for USD/EUR. Required when
/// `price_currency` is supplied; ignored otherwise.
#[serde(default)]
pub price_value: Option<i64>,
#[serde(default)]
pub metadata: Value,
/// Entitlements catalog (migration 0014). Closed list of
/// {slug, name, description} the operator declares the product
/// offers. Policies must reference slugs from this catalog at
/// write time. Omit / leave null to keep "free-text" mode where
/// policies can carry any entitlement string.
#[serde(default)]
pub entitlements_catalog: Option<Vec<crate::models::EntitlementDef>>,
}
/// Currencies the admin endpoints accept. Whitelist enforced here so
/// a typo or future code error can't write a product with a bogus
/// currency tag that the daemon doesn't know how to convert.
const ACCEPTED_CURRENCIES: &[&str] = &["SAT", "USD", "EUR"];
/// Validate + normalize the request's price representation. Returns
/// `(currency, value_in_smallest_unit)`. Errors with 400 on:
/// - both `price_sats` and `price_currency` missing
/// - non-positive value
/// - unknown currency code
/// - both forms supplied with mismatched values (catches half-
/// migrated clients that send stale `price_sats` alongside a
/// fresh `price_value`)
fn resolve_price(req: &CreateProductReq) -> AppResult<(String, i64)> {
match (req.price_currency.as_deref(), req.price_value, req.price_sats) {
// Typed form — preferred.
(Some(cur), Some(value), maybe_legacy) => {
let cur = cur.to_uppercase();
if !ACCEPTED_CURRENCIES.iter().any(|c| *c == cur) {
return Err(AppError::BadRequest(format!(
"unsupported price_currency '{cur}'; accepted: {}",
ACCEPTED_CURRENCIES.join(", ")
)));
}
if value <= 0 {
return Err(AppError::BadRequest("price_value must be positive".into()));
}
// If the legacy field was ALSO sent, only accept it if
// the currency is SAT and the numbers match. Anything
// else means the client sent inconsistent state.
if let Some(legacy) = maybe_legacy {
if cur != "SAT" || legacy != value {
return Err(AppError::BadRequest(
"send price_currency + price_value, OR price_sats alone — \
not both with mismatched values".into(),
));
}
}
Ok((cur, value))
}
// Legacy form — back-compat.
(None, None, Some(sats)) => {
if sats <= 0 {
return Err(AppError::BadRequest("price_sats must be positive".into()));
}
Ok(("SAT".to_string(), sats))
}
// Currency without value — incomplete.
(Some(_), None, _) => Err(AppError::BadRequest(
"price_currency was supplied but price_value is missing".into(),
)),
// Value without currency — ambiguous.
(None, Some(_), _) => Err(AppError::BadRequest(
"price_value was supplied but price_currency is missing".into(),
)),
// Nothing.
(None, None, None) => Err(AppError::BadRequest(
"must supply either price_sats (legacy) or price_currency + price_value".into(),
)),
}
}
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);
// Tier-cap gate: Creator caps at 5 products. 402 if over.
crate::api::tier::enforce_product_cap(&state).await?;
// Resolve the typed-currency form and the legacy form into a
// single (currency, value) pair before hitting the repo. New
// callers send price_currency + price_value; legacy callers
// send price_sats alone; sending both is allowed only if the
// currency is SAT and the values match (catches mismatched
// updates from a half-migrated client).
let (price_currency, price_value) = resolve_price(&req)?;
let metadata = if req.metadata.is_null() {
json!({})
} else {
req.metadata
};
let product = repo::create_product_with_currency(
&state.db,
&req.slug,
&req.name,
&req.description,
&price_currency,
price_value,
&metadata,
)
.await?;
// Apply the entitlements catalog (if any) as a follow-up. Done
// separately so the create_product_with_currency signature stays
// narrow and the catalog edit path (set_product_entitlements_catalog)
// is reused for both create + edit.
let product = if let Some(catalog) = req.entitlements_catalog.as_deref() {
repo::set_product_entitlements_catalog(&state.db, &product.id, Some(catalog)).await?
} else {
product
};
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,
}
/// Query options for product / policy delete.
#[derive(Debug, Deserialize)]
pub struct DeleteOpts {
/// When true, cascades through every dependent row — licenses,
/// invoices, discount-code redemptions, machines — instead of
/// refusing with 409. Use only when tinkering or wiping pre-launch
/// test data; in production this destroys customer history.
#[serde(default)]
pub force: bool,
}
/// Hard-delete a product. Two modes:
///
/// - **Safe (default)**: refuses if any invoice or license references
/// the product. Policies and unredeemed product-scoped codes are
/// cascade-deleted along with the product (templates only — no
/// audit-trail value on their own).
///
/// - **Force (`?force=true`)**: also wipes machines → discount
/// redemptions → licenses → invoices in dependency order before
/// removing the product. Destructive; reserved for testing /
/// pre-launch cleanup. Audit log records the cascade counts for
/// forensic backtracking.
pub async fn delete_product(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Query(opts): Query<DeleteOpts>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let product = repo::get_product_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{id}'")))?;
let invoice_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE product_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
let license_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE product_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
if !opts.force && invoice_count + license_count > 0 {
return Err(AppError::Conflict(format!(
"cannot delete product '{}' — it has {} invoice(s) and {} license(s) \
referencing it. Disable it instead (existing licenses keep working; \
the product just stops being available for new purchases). To override \
and wipe all references, use ?force=true.",
product.slug, invoice_count, license_count
)));
}
// Count what we'll cascade — informational, for the audit row + response.
let policy_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM policies WHERE product_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
let code_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM discount_codes WHERE applies_to_product_id = ?",
)
.bind(&id)
.fetch_one(&state.db)
.await?;
let machine_count: i64 = if opts.force {
sqlx::query_scalar(
"SELECT COUNT(*) FROM machines WHERE license_id IN
(SELECT id FROM licenses WHERE product_id = ?)",
)
.bind(&id)
.fetch_one(&state.db)
.await?
} else {
0
};
let redemption_count: i64 = if opts.force {
sqlx::query_scalar(
"SELECT COUNT(*) FROM discount_redemptions WHERE invoice_id IN
(SELECT id FROM invoices WHERE product_id = ?)",
)
.bind(&id)
.fetch_one(&state.db)
.await?
} else {
0
};
// Cascade. Wrapped in a transaction so a partial failure leaves
// consistent state.
let mut tx = state.db.begin().await?;
if opts.force {
// Force: also wipe customer-history rows. Order matters — most
// dependent rows first.
sqlx::query(
"DELETE FROM machines WHERE license_id IN
(SELECT id FROM licenses WHERE product_id = ?)",
)
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM discount_redemptions WHERE invoice_id IN
(SELECT id FROM invoices WHERE product_id = ?)",
)
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM licenses WHERE product_id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM invoices WHERE product_id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
}
sqlx::query("DELETE FROM discount_codes WHERE applies_to_product_id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM policies WHERE product_id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM products WHERE id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
if opts.force { "product.force_delete" } else { "product.delete" },
Some("product"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"slug": product.slug,
"name": product.name,
"force": opts.force,
"cascaded_policies": policy_count,
"cascaded_codes": code_count,
"cascaded_licenses": if opts.force { license_count } else { 0 },
"cascaded_invoices": if opts.force { invoice_count } else { 0 },
"cascaded_machines": machine_count,
"cascaded_redemptions": redemption_count,
}),
)
.await;
Ok(Json(json!({
"ok": true,
"deleted": product.slug,
"force": opts.force,
"cascaded_policies": policy_count,
"cascaded_codes": code_count,
"cascaded_licenses": if opts.force { license_count } else { 0 },
"cascaded_invoices": if opts.force { invoice_count } else { 0 },
"cascaded_machines": machine_count,
"cascaded_redemptions": redemption_count,
})))
}
/// Patch mutable fields on a product. Slug is NOT editable — it's part
/// of the public buy URL.
///
/// Two pricing forms accepted, mirroring the create endpoint:
/// - Legacy: `price_sats` alone (treated as a SAT-currency update).
/// - Typed: `price_currency` + `price_value`. Either both or neither.
/// Sending a different currency than the product's current one
/// IS allowed — operators can convert a SAT product to USD pricing
/// in place. The daemon doesn't auto-recompute the sat-equivalent
/// for past invoices; future invoices use the new currency.
#[derive(Debug, Deserialize)]
pub struct UpdateProductReq {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub price_sats: Option<i64>,
#[serde(default)]
pub price_currency: Option<String>,
#[serde(default)]
pub price_value: Option<i64>,
/// Replace the entitlements catalog. `Some(vec)` sets it,
/// `Some(empty vec)` clears it (drops back to free-text mode),
/// omit / `None` to leave alone. Note: clearing is potentially
/// destructive — existing policies that reference now-orphaned
/// slugs keep working but new policies / edits will accept any
/// string until the catalog is set again.
#[serde(default, deserialize_with = "deser_double_option_catalog", skip_serializing_if = "Option::is_none")]
pub entitlements_catalog: Option<Option<Vec<crate::models::EntitlementDef>>>,
}
/// Serde adapter — distinguishes "field omitted" (None) from
/// "field supplied as null" (Some(None)) from "field supplied with
/// value" (Some(Some(...))). Same nullable-patch shape used for
/// price_sats_override on policies.
fn deser_double_option_catalog<'de, D>(
de: D,
) -> Result<Option<Option<Vec<crate::models::EntitlementDef>>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<Vec<crate::models::EntitlementDef>>::deserialize(de).map(Some)
}
pub async fn update_product(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<UpdateProductReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// Resolve the pricing patch into (currency, value, sats) tuple
// before passing to the repo. This mirrors the create-side
// `resolve_price` validation so the same accept-both-forms
// semantics apply on PATCH.
let pricing_patch: Option<(String, i64)> = match (
req.price_currency.as_deref(),
req.price_value,
req.price_sats,
) {
// Typed form
(Some(cur), Some(value), maybe_legacy) => {
let cur = cur.to_uppercase();
if !ACCEPTED_CURRENCIES.iter().any(|c| *c == cur) {
return Err(AppError::BadRequest(format!(
"unsupported price_currency '{cur}'; accepted: {}",
ACCEPTED_CURRENCIES.join(", ")
)));
}
if value < 0 {
return Err(AppError::BadRequest("price_value must be >= 0".into()));
}
if let Some(legacy) = maybe_legacy {
if cur != "SAT" || legacy != value {
return Err(AppError::BadRequest(
"send price_currency + price_value, OR price_sats alone — \
not both with mismatched values".into(),
));
}
}
Some((cur, value))
}
// Legacy SAT-only.
(None, None, Some(sats)) => {
if sats < 0 {
return Err(AppError::BadRequest("price_sats must be >= 0".into()));
}
Some(("SAT".to_string(), sats))
}
(Some(_), None, _) => {
return Err(AppError::BadRequest(
"price_currency was supplied but price_value is missing".into(),
));
}
(None, Some(_), _) => {
return Err(AppError::BadRequest(
"price_value was supplied but price_currency is missing".into(),
));
}
// No pricing change — nothing to validate.
(None, None, None) => None,
};
let updated = repo::update_product_with_currency(
&state.db,
&id,
req.name.as_deref(),
req.description.as_deref(),
pricing_patch.as_ref().map(|(c, v)| (c.as_str(), *v)),
)
.await?;
// If the patch touched entitlements_catalog, apply it as a
// separate UPDATE. Some(Some(vec)) sets, Some(Some(empty vec))
// and Some(None) both clear (drop back to free-text mode).
let updated = match &req.entitlements_catalog {
Some(Some(catalog)) => {
repo::set_product_entitlements_catalog(&state.db, &id, Some(catalog.as_slice())).await?
}
Some(None) => {
repo::set_product_entitlements_catalog(&state.db, &id, None).await?
}
None => updated,
};
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"product.update",
Some("product"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"name": req.name,
"description": req.description,
"price_sats": req.price_sats,
}),
)
.await;
Ok(Json(json!(updated)))
}
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. With no filters supplied, returns the 100 most-recent
/// licenses (used by the admin UI's "recent licenses" default view).
///
/// Each row is hydrated with `policy_slug`, `policy_name`, and `product_slug`
/// so the admin UI can render those without extra round-trips.
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?;
// Hydrate with policy + product slugs. Two small lookup queries against
// the unique ids referenced; cheap even for the 100-row max page.
let policy_ids: Vec<String> = licenses
.iter()
.filter_map(|l| l.policy_id.clone())
.collect();
let product_ids: Vec<String> = licenses
.iter()
.map(|l| l.product_id.clone())
.collect();
let mut policy_map: std::collections::HashMap<String, (String, String)> =
std::collections::HashMap::new();
if !policy_ids.is_empty() {
let placeholders = vec!["?"; policy_ids.len()].join(",");
let sql = format!("SELECT id, slug, name FROM policies WHERE id IN ({placeholders})");
let mut q = sqlx::query_as::<_, (String, String, String)>(&sql);
for id in &policy_ids {
q = q.bind(id);
}
for (id, slug, name) in q.fetch_all(&state.db).await? {
policy_map.insert(id, (slug, name));
}
}
let mut product_map: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
if !product_ids.is_empty() {
let placeholders = vec!["?"; product_ids.len()].join(",");
let sql = format!("SELECT id, slug FROM products WHERE id IN ({placeholders})");
let mut q = sqlx::query_as::<_, (String, String)>(&sql);
for id in &product_ids {
q = q.bind(id);
}
for (id, slug) in q.fetch_all(&state.db).await? {
product_map.insert(id, slug);
}
}
let enriched: Vec<Value> = licenses
.into_iter()
.map(|l| {
let mut v = serde_json::to_value(&l).unwrap_or(json!({}));
if let Some(pid) = &l.policy_id {
if let Some((slug, name)) = policy_map.get(pid) {
v["policy_slug"] = json!(slug);
v["policy_name"] = json!(name);
}
}
if let Some(slug) = product_map.get(&l.product_id) {
v["product_slug"] = json!(slug);
}
v
})
.collect();
Ok(Json(json!({ "licenses": enriched })))
}
/// Lifetime / 30d / 7d / 24h revenue from settled BTCPay invoices stored
/// locally. Powers the admin Overview "Revenue" stat card. Free-license
/// invoices have amount_sats = 0 and don't contribute. We deliberately
/// don't call the BTCPay API here — the local DB has every invoice we
/// ever created, including amount and status, so summing locally is
/// faster and works even if BTCPay is temporarily unreachable. (If we
/// ever want refunds / fees / chargebacks / Lightning vs on-chain
/// breakdown, that's when we'd hit BTCPay's API.)
pub async fn revenue_summary(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let total: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount_sats), 0) FROM invoices WHERE status = 'settled'",
)
.fetch_one(&state.db)
.await?;
let last_24h: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount_sats), 0) FROM invoices
WHERE status = 'settled' AND updated_at >= datetime('now','-24 hours')",
)
.fetch_one(&state.db)
.await?;
let last_7d: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount_sats), 0) FROM invoices
WHERE status = 'settled' AND updated_at >= datetime('now','-7 days')",
)
.fetch_one(&state.db)
.await?;
let last_30d: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount_sats), 0) FROM invoices
WHERE status = 'settled' AND updated_at >= datetime('now','-30 days')",
)
.fetch_one(&state.db)
.await?;
let settled_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE status = 'settled' AND amount_sats > 0")
.fetch_one(&state.db)
.await?;
Ok(Json(json!({
"total_sats": total,
"last_24h_sats": last_24h,
"last_7d_sats": last_7d,
"last_30d_sats": last_30d,
"settled_paid_invoice_count": settled_count,
})))
}
/// License counts grouped by product_id and policy_id. Powers the
/// "X licenses" badge on the Products and Policies tables. Two small
/// COUNT-by-group queries; cheap to run on every Products/Policies route
/// open.
pub async fn license_counts(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let by_product: Vec<(String, i64)> = sqlx::query_as(
"SELECT product_id, COUNT(*) FROM licenses GROUP BY product_id",
)
.fetch_all(&state.db)
.await?;
let by_policy: Vec<(Option<String>, i64)> = sqlx::query_as(
"SELECT policy_id, COUNT(*) FROM licenses GROUP BY policy_id",
)
.fetch_all(&state.db)
.await?;
let by_product_map: serde_json::Map<String, Value> = by_product
.into_iter()
.map(|(id, n)| (id, Value::from(n)))
.collect();
let by_policy_map: serde_json::Map<String, Value> = by_policy
.into_iter()
.filter_map(|(id, n)| id.map(|i| (i, Value::from(n))))
.collect();
Ok(Json(json!({
"by_product": by_product_map,
"by_policy": by_policy_map,
})))
}
/// Aggregate counts for the admin Overview dashboard. Populates the
/// "Active licenses" stat card (and is small/cheap enough to query on
/// every dashboard load).
pub async fn licenses_summary(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses")
.fetch_one(&state.db)
.await?;
let active: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE status = 'active'")
.fetch_one(&state.db)
.await?;
let suspended: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE status = 'suspended'")
.fetch_one(&state.db)
.await?;
let revoked: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE status = 'revoked'")
.fetch_one(&state.db)
.await?;
let last_24h: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM licenses WHERE issued_at >= datetime('now','-24 hours')",
)
.fetch_one(&state.db)
.await?;
let last_7d: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM licenses WHERE issued_at >= datetime('now','-7 days')",
)
.fetch_one(&state.db)
.await?;
Ok(Json(json!({
"total": total,
"active": active,
"suspended": suspended,
"revoked": revoked,
"last_24h": last_24h,
"last_7d": last_7d,
})))
}
#[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,
})))
}