v0.1.0:25–40 — tier model, edit forms, force-delete, license counts, migration 0009 (and hotfix); KEYSAT_INTEGRATION.md merged with downstream-LLM revisions

This commit is contained in:
Grant
2026-05-07 23:35:22 -05:00
parent 6ac118ae70
commit beedd07f07
27 changed files with 5576 additions and 134 deletions
+326 -1
View File
@@ -42,7 +42,7 @@ pub struct CreatePolicyReq {
pub entitlements: Vec<String>,
#[serde(default)]
pub metadata: Value,
/// Optional Lightning recipient (e.g. "tip@keysat.xyz") to tip a percentage
/// Optional Lightning recipient (e.g. "keysat@primal.net") to tip a percentage
/// of each successful issuance to. None = no tipping.
#[serde(default)]
pub tip_recipient: Option<String>,
@@ -69,6 +69,9 @@ pub async fn create(
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product_slug)))?;
// Tier-cap gate: Creator caps at 5 policies per product.
crate::api::tier::enforce_policy_cap(&state, &product.id).await?;
if req.duration_seconds < 0 {
return Err(AppError::BadRequest("duration_seconds must be >= 0".into()));
}
@@ -182,6 +185,328 @@ pub async fn set_active(
Ok(Json(json!({ "ok": true })))
}
#[derive(Debug, Deserialize)]
pub struct PolicyDeleteOpts {
#[serde(default)]
pub force: bool,
}
/// Hard-delete a policy. Two modes:
///
/// - **Safe (default)**: refuses if any invoice or license references
/// the policy. Operator should use Hide / Disable instead in that case.
///
/// - **Force (`?force=true`)**: cascades through machines → redemptions →
/// licenses → invoices for that policy_id before removing the policy.
/// Audit-logged with cascade counts.
pub async fn delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Query(opts): Query<PolicyDeleteOpts>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let policy = repo::get_policy_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("policy '{id}'")))?;
let invoice_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE policy_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
let license_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE policy_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
if !opts.force && invoice_count + license_count > 0 {
return Err(AppError::Conflict(format!(
"cannot delete policy '{}' — it has {} invoice(s) and {} license(s) \
referencing it. Disable it via the active toggle, or hide it from the \
buy page via the public toggle, instead. To override and wipe all \
references, use ?force=true.",
policy.slug, invoice_count, license_count
)));
}
let machine_count: i64 = if opts.force {
sqlx::query_scalar(
"SELECT COUNT(*) FROM machines WHERE license_id IN
(SELECT id FROM licenses WHERE policy_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 policy_id = ?)",
)
.bind(&id)
.fetch_one(&state.db)
.await?
} else {
0
};
let mut tx = state.db.begin().await?;
if opts.force {
sqlx::query(
"DELETE FROM machines WHERE license_id IN
(SELECT id FROM licenses WHERE policy_id = ?)",
)
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM discount_redemptions WHERE invoice_id IN
(SELECT id FROM invoices WHERE policy_id = ?)",
)
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM licenses WHERE policy_id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM invoices WHERE policy_id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
}
sqlx::query("DELETE FROM policies 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 { "policy.force_delete" } else { "policy.delete" },
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"slug": policy.slug,
"name": policy.name,
"force": opts.force,
"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": policy.slug,
"force": opts.force,
"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 policy. Slug + product are NOT editable —
/// they're identifiers operators may have hard-coded into integration
/// docs or buy URLs. Tip config has its own dedicated endpoint
/// (`PATCH /v1/admin/policies/:id/tip`).
#[derive(Debug, Deserialize)]
pub struct UpdatePolicyReq {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub duration_seconds: Option<i64>,
#[serde(default)]
pub grace_seconds: Option<i64>,
#[serde(default)]
pub max_machines: Option<i64>,
#[serde(default)]
pub is_trial: Option<bool>,
/// Use `Some(Some(n))` to set a tier price, `Some(null)` to clear and
/// fall back to the product's base price.
#[serde(default, deserialize_with = "deser_double_option_i64", skip_serializing_if = "Option::is_none")]
pub price_sats_override: Option<Option<i64>>,
#[serde(default)]
pub entitlements: Option<Vec<String>>,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
}
fn deser_double_option_i64<'de, D>(de: D) -> Result<Option<Option<i64>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<i64>::deserialize(de).map(Some)
}
pub async fn update(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<UpdatePolicyReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if let Some(d) = req.duration_seconds {
if d < 0 {
return Err(AppError::BadRequest("duration_seconds must be >= 0".into()));
}
}
if let Some(g) = req.grace_seconds {
if g < 0 {
return Err(AppError::BadRequest("grace_seconds must be >= 0".into()));
}
}
if let Some(m) = req.max_machines {
if m < 0 {
return Err(AppError::BadRequest("max_machines must be >= 0".into()));
}
}
let updated = repo::update_policy(
&state.db,
&id,
req.name.as_deref(),
req.duration_seconds,
req.grace_seconds,
req.max_machines,
req.is_trial,
req.price_sats_override,
req.entitlements.as_deref(),
req.metadata.as_ref(),
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.update",
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"name": req.name,
"duration_seconds": req.duration_seconds,
"max_machines": req.max_machines,
"price_sats_override": req.price_sats_override,
"entitlements": req.entitlements,
}),
)
.await;
Ok(Json(json!(updated)))
}
#[derive(Debug, Deserialize)]
pub struct SetPublicReq {
pub public: bool,
}
/// Toggle whether a policy is rendered as a tier-card on /buy/<slug>.
/// Private policies remain usable from admin issuance, but are excluded
/// from the public tier picker.
pub async fn set_public(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetPublicReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_policy_public(&state.db, &id, req.public).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.set_public",
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "public": req.public }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
// ---------- Public buyer endpoint ----------
/// Public (no-auth): `GET /v1/products/:slug/policies` — used by the buy
/// page tier picker. Returns the product (slug, name, description, base
/// price) and an array of active+public policies, each with the fields a
/// buyer needs to decide between tiers (name, slug, description from
/// metadata, price_sats, duration_seconds, max_machines, is_trial,
/// entitlements). Internal/admin fields (id, tip recipient, raw metadata,
/// created_at) are deliberately omitted.
pub async fn list_public_policies(
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}'")))?;
if !product.active {
return Err(AppError::NotFound(format!("product '{slug}'")));
}
let policies = repo::list_public_policies_by_product(&state.db, &product.id).await?;
let policies_json: Vec<Value> = policies
.into_iter()
.map(|p| {
// Description: pulled from metadata.description if present, so
// operators can write a buyer-friendly per-tier blurb without a
// schema change. Falls back to "" if absent.
let description = p
.metadata
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Highlight: same pattern — metadata.highlight = true marks the
// "most popular" tier so the buy page can render a gold ribbon.
let highlighted = p
.metadata
.get("highlight")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let price_sats = p.price_sats_override.unwrap_or(product.price_sats);
json!({
"slug": p.slug,
"name": p.name,
"description": description,
"price_sats": price_sats,
"duration_seconds": p.duration_seconds,
"max_machines": p.max_machines,
"is_trial": p.is_trial,
"entitlements": p.entitlements,
"highlighted": highlighted,
})
})
.collect();
Ok(Json(json!({
"product": {
"slug": product.slug,
"name": product.name,
"description": product.description,
"base_price_sats": product.price_sats,
},
"policies": policies_json,
})))
}
#[derive(Debug, Deserialize)]
pub struct SetTipReq {
/// Lightning Address (`user@domain`). Pass `null` to disable tipping.