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
+62 -27
View File
@@ -3,28 +3,29 @@
//! Keysat ships in three tiers. The daemon enforces caps based on the
//! entitlements baked into its own self-license (see `license_self.rs`):
//!
//! - **Creator** (default, also the unlicensed default): caps at 5
//! products, 5 policies per product, 5 active discount codes. Buyers
//! get a real Keysat brand experience for hobbyist scale. Sold at
//! keysat.xyz for ~21,000 sats; also distributable via free codes.
//! - **Creator** (free, no self-license required): caps at 5 products,
//! 5 policies per product, 10 active discount codes. Buyers get a
//! real Keysat brand experience for hobbyist scale. Anyone who
//! installs Keysat is on Creator out of the box — no signup, no
//! trial.
//! - **Pro**: unlimited products / policies / codes. Unlocks
//! `recurring_billing` and `card_payments` (Zaprite) when those
//! features ship in v0.3. Sold at keysat.xyz for ~250,000 sats / yr.
//! `recurring_billing` and `zaprite_payments` (Zaprite gateway —
//! cards, Apple Pay, bank transfers, in addition to Bitcoin). Sold
//! at keysat.xyz for ~250,000 sats / yr.
//! - **Patron**: same feature surface as Pro, plus a `patron`
//! entitlement that renders a "Patron" badge in the admin topbar.
//! Honest upsell — no fake feature gate. Sold for ~500,000 sats / yr.
//!
//! "Unlicensed" (no self-license file present) is treated as Creator-tier
//! caps: operators can install Keysat and start shipping without paying
//! us a sat. The pull to a paid tier happens organically when they need
//! more than 5 products or want recurring billing.
//! The pull from Creator to a paid tier happens organically: operators
//! hit the 5-product cap, or want recurring billing, or want to accept
//! cards via Zaprite. All three trigger a 402 with an upgrade URL.
//!
//! All tier judgments are derived from the `entitlements` array on the
//! daemon's self-license. The presence of `unlimited_products` lifts
//! the product cap; `unlimited_policies` lifts the policy-per-product
//! cap; `unlimited_codes` lifts the code cap. `recurring_billing` and
//! `card_payments` gate the Zaprite + recurring features (when those
//! ship). `patron` is purely cosmetic.
//! cap; `unlimited_codes` lifts the code cap. `recurring_billing` gates
//! creating recurring policies; `zaprite_payments` gates Connect/Activate
//! Zaprite. `patron` is purely cosmetic.
//!
//! The cap enforcement returns 402 Payment Required with an `upgrade_url`
//! pointing at the master Keysat's buy page so the admin SPA can render
@@ -34,14 +35,19 @@ use crate::api::AppState;
use crate::error::{AppError, AppResult};
use crate::license_self::Tier;
/// Tier-cap ceilings for the entry-level "Creator" tier (and unlicensed
/// installs, which inherit the same caps). Tunable as we learn more from
/// real operator usage post-launch — change the constants here. Existing
/// operators are never retroactively kicked off; the cap fires at
/// create-time only.
/// Tier-cap ceilings for the entry-level "Creator" tier — the default
/// state when no self-license is present and the surfaced label whenever
/// a license's entitlements don't include `unlimited_products`. Tunable
/// as we learn more from real operator usage post-launch — change the
/// constants here. Existing operators are never retroactively kicked
/// off; the cap fires at create-time only.
pub const CREATOR_PRODUCT_CAP: i64 = 5;
pub const CREATOR_POLICY_CAP_PER_PRODUCT: i64 = 5;
pub const CREATOR_CODE_CAP: i64 = 5;
/// Creator-tier active-discount-code cap. Sized so a launch operator
/// can run several concurrent promo campaigns (launch week, early bird,
/// newsletter, speaker codes, etc.) without conversion-pressure that
/// doesn't actually map to scale. Disabled codes don't count.
pub const CREATOR_CODE_CAP: i64 = 10;
/// Where the upgrade banner / 402 error sends an operator to buy a
/// higher tier. Hard-coded to the canonical master Keysat. Eventually
@@ -54,11 +60,11 @@ pub const UPGRADE_URL_PATRON: &str = "https://licensing.keysat.xyz/buy/keysat?po
/// for UI consumption.
#[derive(Debug, Clone)]
pub struct TierInfo {
/// Coarse label: "creator" | "pro" | "patron" | "unlicensed".
/// Coarse label: "creator" | "pro" | "patron".
pub label: &'static str,
/// Display-friendly name: "Creator" | "Pro" | "Patron" | "Unlicensed".
/// Display-friendly name: "Creator" | "Pro" | "Patron".
pub display_name: &'static str,
/// The full entitlement set baked into the self-license, or empty if unlicensed.
/// The full entitlement set baked into the self-license; empty for Creator.
pub entitlements: Vec<String>,
}
@@ -77,6 +83,11 @@ impl TierInfo {
/// Read the daemon's self-tier and project to a TierInfo for tier-aware
/// code paths. Async because state.self_tier is wrapped in a tokio RwLock
/// (allows `Activate Keysat license` to swap it without a daemon restart).
///
/// A missing self-license surfaces as Creator (the free tier) — the daemon
/// always boots, the Creator caps apply, and the admin UI shows "Creator"
/// rather than "Unlicensed" to avoid the implication that something needs
/// to be fixed.
pub async fn current(state: &AppState) -> TierInfo {
let tier = state.self_tier.read().await;
let entitlements = match &*tier {
@@ -93,12 +104,10 @@ pub async fn current(state: &AppState) -> TierInfo {
} else if entitlements.iter().any(|e| e == "unlimited_products") {
label = "pro";
display_name = "Pro";
} else if entitlements.iter().any(|e| e == "self_host") {
} else {
// No paid entitlements present (or no self-license at all) → Creator.
label = "creator";
display_name = "Creator";
} else {
label = "unlicensed";
display_name = "Unlicensed";
}
TierInfo {
label,
@@ -142,7 +151,7 @@ pub async fn admin_status(
},
});
let next_tier = match tier.label {
"creator" | "unlicensed" => "pro",
"creator" => "pro",
"pro" => "patron",
_ => "patron",
};
@@ -232,6 +241,32 @@ pub async fn enforce_recurring_feature(state: &AppState) -> AppResult<()> {
})
}
/// Refuse to connect or activate Zaprite unless the operator's self-tier
/// carries the `zaprite_payments` entitlement. Pro and Patron tiers have
/// it; Creator does not. Zaprite is the buyer-side optionality story —
/// cards, Apple Pay, bank transfers, plus Bitcoin — so this gate is the
/// upgrade pressure for operators who want to accept payment methods
/// beyond Bitcoin / Lightning via BTCPay. Called from both the initial
/// Connect Zaprite flow and the Activate-Zaprite switch, so an operator
/// can't sneak past by connecting on Pro and downgrading later (the
/// downgrade flow doesn't auto-disconnect Zaprite, but a switch attempt
/// after downgrade is refused).
pub async fn enforce_zaprite_feature(state: &AppState) -> AppResult<()> {
let tier = current(state).await;
if tier.has("zaprite_payments") {
return Ok(());
}
Err(AppError::PaymentRequired {
message: format!(
"Zaprite payment gateway (cards, Apple Pay, bank transfers, and more) \
requires Pro or Patron. You're on {}. BTCPay (Bitcoin / Lightning) \
remains available on every tier.",
tier.display_name
),
upgrade_url: UPGRADE_URL_PRO.to_string(),
})
}
/// Refuse a new discount code if the operator is at the Creator-tier
/// active-codes cap and lacks `unlimited_codes`. Counts only ACTIVE
/// codes — operators can disable old codes to free up slots, which is