Files
keysat/licensing-service/src/api/zaprite_authorize.rs
T
Grant 257669092b v0.2.0:11 + v0.2.0:12 — Archive, Settings, agent surface, machines redesign
Two release cycles prepared together: v0.2.0:11 (policy archive + safe-
delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings
tab + agent-friendly operator API + machines tab redesign + buyer-facing
copy alignment).

Highlights:

- Migration 0015: policies.archived_at column. Archive button on tier
  cards; safe-delete relaxed to ignore revoked-license tombstones;
  renewal worker refuses archived policies.
- Migration 0016: scoped_api_keys table. Four roles (read-only,
  license-issuer, support, full-admin) with bounded scopes. Master
  admin_api_key still works on every endpoint; scoped keys gated on
  endpoints wired through require_scope().
- New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec
  for agent / SDK discovery.
- New Settings tab: Operator name + Payment providers panel + API
  keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay
  all, operator name, switch-provider). StartOS Actions pruned to 4
  install-time essentials.
- Machines tab rewritten: global default view grouped by product,
  filter pills with counts, quick-stats row, drill-down via new
  "Machines" button on each Licenses-tab row. New repo helper
  list_machines_admin joins machines x licenses x products
  server-side.
- Branded confirmModal replaces every native window.confirm() call
  in the admin UI (7 callsites).
- Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag
  retired; daemon always boots; missing self-license -> Creator
  (free) tier. "Unlicensed" label gone from admin UI.
- Zaprite gated on the new zaprite_payments entitlement (renamed
  from card_payments to reflect the broader gateway).
- Creator code cap 5 -> 10.
- KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope,
  webhook events, worked recipes.
- Buyer-facing copy aligned with new positioning: "Bitcoin-native
  self-hosted software licensing" everywhere on production surfaces.
- Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md.
- 5 new API integration smoke tests covering OpenAPI, scoped API
  keys CRUD, role-elevation guard, and Zaprite-tier gating.

Test count: 83 passing (was 78). All migration tests pass against
0015 and 0016 applied to populated DBs.
2026-05-11 08:45:25 -05:00

258 lines
9.5 KiB
Rust

//! Zaprite connect / disconnect / status admin endpoints.
//!
//! Zaprite doesn't expose an OAuth-style consent flow the way
//! BTCPay does — there's no `/authorize` redirect chain. Operators
//! just create an API key in their Zaprite dashboard and paste it
//! in. So this module is much smaller than `btcpay_authorize.rs`:
//! a single connect endpoint validates + stores the key, a
//! disconnect endpoint wipes it, a status endpoint reports state.
//!
//! The active provider on `AppState` is swapped atomically as part
//! of connect/disconnect so request handlers immediately see the
//! new state without a daemon restart.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use crate::payment::zaprite::{
config as zaprite_config, ZapriteClient, ZapriteProvider,
};
use axum::{extract::State, http::HeaderMap, Json};
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
const DEFAULT_BASE_URL: &str = "https://api.zaprite.com";
#[derive(Debug, Deserialize)]
pub struct ConnectReq {
pub api_key: String,
/// Optional override — defaults to https://api.zaprite.com.
/// Useful for sandbox orgs that point at a different host or
/// for future regional endpoints.
#[serde(default)]
pub base_url: Option<String>,
}
/// `POST /v1/admin/zaprite/connect` — validate + store an API
/// key, then swap the active payment provider to Zaprite. The
/// operator pastes the key from
/// `app.zaprite.com/.../settings/api`.
///
/// Validates the key by calling `GET /v1/orders?limit=1` against
/// Zaprite — auth-guarded, so a 200 confirms the key works for
/// the right org. A 401 / 403 / network error short-circuits
/// before we persist anything.
pub async fn connect(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<ConnectReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
crate::api::tier::enforce_zaprite_feature(&state).await?;
let (ip, ua) = request_context(&headers);
let api_key = req.api_key.trim().to_string();
if api_key.is_empty() {
return Err(AppError::BadRequest("api_key is required".into()));
}
// Short-circuit: refuse to overwrite an existing config silently.
// Operators get confused when they re-run Connect after already
// being connected — they expect a "you're already set up" message,
// not a form re-prompt that can clobber their working config.
if let Ok(Some(_)) = zaprite_config::load(&state.db).await {
return Err(AppError::Conflict(
"Zaprite is already connected. Run 'Disconnect Zaprite' first \
if you want to rotate the API key or switch organizations."
.into(),
));
}
let base_url = req
.base_url
.as_deref()
.map(|s| s.trim().trim_end_matches('/'))
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_BASE_URL)
.to_string();
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
return Err(AppError::BadRequest(
"base_url must start with http:// or https://".into(),
));
}
// Smoke-test the key before saving anything. Zaprite will
// 401 a bad key — surface that as a clean operator-facing
// error rather than letting it crash later in the purchase
// flow.
let client = ZapriteClient::new(&base_url, &api_key);
client.ping().await.map_err(|e| {
AppError::Upstream(format!(
"Zaprite key validation failed (key may be invalid or revoked): {e:#}"
))
})?;
// Persist + swap.
zaprite_config::save(
&state.db,
&zaprite_config::ZapriteConfig {
api_key: api_key.clone(),
base_url: base_url.clone(),
webhook_id: None, // operator configures the webhook in Zaprite's dashboard
},
)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("save zaprite_config: {e:#}")))?;
let provider = ZapriteProvider::new(client);
state
.set_payment_provider(Arc::new(provider))
.await;
// Persist the operator's preference so the boot-time loader
// picks Zaprite on next restart, even if BTCPay's config row
// is also still in the DB.
crate::payment::write_active_provider_preference(
&state.db,
crate::payment::ProviderKind::Zaprite,
)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("write provider preference: {e:#}")))?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"zaprite.connect",
Some("payment_provider"),
Some("zaprite"),
ip.as_deref(),
ua.as_deref(),
&json!({ "base_url": base_url }),
)
.await;
// Compute the absolute webhook URL so the StartOS Action can
// surface the full https://... endpoint to the operator. They
// paste this into the Zaprite dashboard exactly. Zaprite's
// webhook form requires a full URL, not a path; the previous
// copy showed a placeholder which was confusing.
let webhook_url = format!(
"{}/v1/zaprite/webhook",
state.config.public_base_url.trim_end_matches('/')
);
Ok(Json(json!({
"ok": true,
"provider": "zaprite",
"base_url": base_url,
"webhook_url": webhook_url,
})))
}
/// `POST /v1/admin/zaprite/disconnect` — wipe the stored key,
/// clear the active provider. Operator should also delete the
/// corresponding webhook on Zaprite's side, but we don't reach
/// out to Zaprite to delete it — the operator uses Zaprite's
/// dashboard for that. We can't delete it programmatically because
/// Zaprite's webhook-management endpoints aren't on the public
/// OpenAPI we have access to.
pub async fn disconnect(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// No-op if nothing's connected.
let existing = zaprite_config::load(&state.db).await.map_err(|e| {
AppError::Internal(anyhow::anyhow!("load zaprite_config: {e:#}"))
})?;
if existing.is_none() {
return Ok(Json(json!({
"ok": true,
"noop": true,
"message": "Zaprite was not connected",
})));
}
zaprite_config::clear(&state.db).await.map_err(|e| {
AppError::Internal(anyhow::anyhow!("clear zaprite_config: {e:#}"))
})?;
state.clear_payment_provider().await;
// If the active-provider preference was Zaprite, clear it.
// Don't blindly clear if it was BTCPay — that's a different
// operator's choice we shouldn't undo just because they ran
// Disconnect Zaprite.
if matches!(
crate::payment::read_active_provider_preference(&state.db).await,
Some(crate::payment::ProviderKind::Zaprite)
) {
let _ = crate::db::repo::settings_set(
&state.db,
crate::payment::SETTING_ACTIVE_PROVIDER,
None,
)
.await;
}
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"zaprite.disconnect",
Some("payment_provider"),
Some("zaprite"),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
Ok(Json(json!({
"ok": true,
"noop": false,
"message": "Zaprite disconnected. Don't forget to delete the corresponding webhook on Zaprite's side at app.zaprite.com.",
})))
}
/// `GET /v1/admin/zaprite/status` — operator-facing connection
/// snapshot. Reports whether Zaprite is the active provider, the
/// base URL, and whether a webhook id has been recorded. Does NOT
/// return the API key (mirroring how btcpay/status redacts).
pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = zaprite_config::load(&state.db).await.map_err(|e| {
AppError::Internal(anyhow::anyhow!("load zaprite_config: {e:#}"))
})?;
let active_provider = match state.payment.read().await.as_ref() {
Some(p) => Some(p.kind().as_str().to_string()),
None => None,
};
let webhook_url = format!(
"{}/v1/zaprite/webhook",
state.config.public_base_url.trim_end_matches('/')
);
Ok(Json(json!({
"connected": cfg.is_some(),
"active_provider": active_provider,
"base_url": cfg.as_ref().map(|c| c.base_url.clone()),
"webhook_id": cfg.as_ref().and_then(|c| c.webhook_id.clone()),
// Surfaced unconditionally so an operator who lost the
// first-connect message can still find the URL to paste
// into Zaprite's dashboard. Webhook-not-yet-registered
// doesn't change the URL — it's the same address Zaprite
// would POST to once registered.
"webhook_url": webhook_url,
"webhook_explainer": "Zaprite doesn't sign webhook deliveries. \
Keysat authenticates each delivery via the externalUniqId we attach \
at order creation, so a webhook configured to ANY URL on your daemon \
is safe even without a shared secret. Polling /v1/orders works as a \
fallback if you don't register the webhook, but webhooks fire on \
payment settle and let Keysat issue the license within a second \
instead of the next reconcile-loop tick (every 60s).",
})))
}