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.
This commit is contained in:
Grant
2026-05-11 08:45:25 -05:00
parent 20b5293c81
commit 257669092b
25 changed files with 2980 additions and 384 deletions
+34 -4
View File
@@ -236,11 +236,24 @@ pub async fn deactivate(
// ---------- Admin endpoints ----------
/// Query for the admin Machines list. All filters are optional and
/// conjunctive — leaving them all blank returns every machine across
/// every license, default-sorted by most-recent heartbeat. The admin UI
/// Machines tab uses this default-no-filter form to render a global
/// view; the Licenses-tab drill-down sets `license_id`.
#[derive(Debug, Deserialize)]
pub struct AdminListQuery {
pub license_id: String,
#[serde(default)]
pub license_id: Option<String>,
#[serde(default)]
pub product_id: Option<String>,
#[serde(default)]
pub product_slug: Option<String>,
#[serde(default)]
pub include_inactive: bool,
/// Cap on result size; defaults to 500. Admin UI paginates client-side.
#[serde(default)]
pub limit: Option<i64>,
}
pub async fn admin_list(
@@ -249,11 +262,28 @@ pub async fn admin_list(
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?
// Resolve product_slug → product_id if the caller passed the slug
// form. Either works; product_id takes precedence on conflict.
let resolved_product_id: Option<String> = if let Some(pid) = q.product_id.as_deref() {
Some(pid.to_string())
} else if let Some(slug) = q.product_slug.as_deref() {
match repo::get_product_by_slug(&state.db, slug).await? {
Some(p) => Some(p.id),
None => return Err(AppError::NotFound(format!("product '{slug}'"))),
}
} else {
repo::list_active_machines(&state.db, &q.license_id).await?
None
};
let machines = repo::list_machines_admin(
&state.db,
resolved_product_id.as_deref(),
q.license_id.as_deref(),
q.include_inactive,
q.limit.unwrap_or(500).clamp(1, 5000),
)
.await?;
Ok(Json(json!({ "machines": machines })))
}