257669092b
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.
258 lines
9.5 KiB
Rust
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).",
|
|
})))
|
|
}
|