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
+1 -1
View File
@@ -219,4 +219,4 @@ Whatever you pick, hash it before sending if you want to avoid exposing the unde
## Tor / `.onion` support
Since licensing-service runs on Start9, it automatically gets a Tor `.onion` address. If you ship a Tor transport in your client, you get censorship-resistant validation for free, which is particularly valuable given the whole stack is Bitcoin-paid and privacy-adjacent.
Since licensing-service runs on Start9, it automatically gets a Tor `.onion` address. If you ship a Tor transport in your client, you get censorship-resistant validation for free, which is particularly valuable given the whole stack is Bitcoin-native and privacy-adjacent.
@@ -0,0 +1,19 @@
-- Migration 0015: policies.archived_at
--
-- Adds a soft-archive flag to policies. An archived policy is hidden
-- from the admin grid (unless the operator opts to show archived) and
-- from the public /buy/<slug> page. Existing licenses keep validating
-- because their entitlements are signed into the LIC1 payload; the
-- policy row is not consulted at validate time. Active recurring
-- subscriptions tied to an archived policy stop renewing — the renewal
-- worker treats archived as a hard stop and surfaces a clear event.
--
-- Why a column instead of a status TEXT enum? Policies already have
-- two boolean toggles (active, public). A nullable timestamp is the
-- minimum-information shape: NULL = live, timestamp = when archived.
-- Useful for sorting "Archived (most recent first)" without an extra
-- column.
ALTER TABLE policies ADD COLUMN archived_at TEXT NULL;
CREATE INDEX IF NOT EXISTS idx_policies_archived_at ON policies(archived_at);
@@ -0,0 +1,37 @@
-- Migration 0016: scoped API keys
--
-- The master `admin_api_key` (set in config) is a full-access credential;
-- it's the right thing for the operator to hold but the wrong thing to
-- hand to an agent or a third-party tool. This table stores additional
-- API keys with operator-chosen roles that bound what they can do.
--
-- Storage model:
-- - `token_hash` is the sha256 of the raw token. We NEVER store the
-- raw token after generation — the create endpoint returns it once
-- in the response body and that's the operator's only chance to
-- copy it.
-- - `role` is a string tag (read-only | license-issuer | support |
-- full-admin). Scope sets per role are computed at auth time, not
-- stored here — that way we can extend the scope mapping without a
-- migration.
-- - `revoked_at` flips this row from valid → permanently rejected.
-- Soft-revoke rather than DELETE so the audit log keeps a stable
-- reference and the operator can see "this key existed but is
-- gone now" in the admin UI.
-- - `last_used_at` is touched best-effort on every successful auth.
-- Bounded write rate (one update per minute per key) is the next
-- iteration; for v1 we update every call.
CREATE TABLE IF NOT EXISTS scoped_api_keys (
id TEXT PRIMARY KEY NOT NULL,
label TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT,
CHECK (role IN ('read-only', 'license-issuer', 'support', 'full-admin'))
);
CREATE INDEX IF NOT EXISTS idx_scoped_api_keys_token ON scoped_api_keys(token_hash);
CREATE INDEX IF NOT EXISTS idx_scoped_api_keys_active ON scoped_api_keys(revoked_at);
+1 -1
View File
@@ -14,7 +14,7 @@
//! identity, store id, or any user-supplied value. Resetting
//! analytics opt-in regenerates it.
//! - `daemon_version` — e.g. `"0.1.0:46"`.
//! - `tier` — `"unlicensed" | "creator" | "pro" | "patron"`.
//! - `tier` — `"creator" | "pro" | "patron"`.
//! - `counts` — rounded down to the nearest 5 to prevent
//! fingerprinting an operator by exact license count.
//! - `uptime_seconds` — bucketed to "<1d" / "1-7d" / "1-4w" / ">4w".
+342
View File
@@ -0,0 +1,342 @@
//! Scoped API keys — additional API keys with bounded permissions.
//!
//! Master credential is the env-configured `admin_api_key` (full access).
//! These scoped keys exist so operators can grant an agent / bot / partner
//! script a credential that does only what it needs to. Operator-friendly
//! flow:
//!
//! 1. Operator generates a new key in Settings → API keys, picks a role
//! from a fixed list (Read-only / License issuer / Support / Full admin).
//! 2. UI returns the raw token ONCE. The token never appears in any
//! response afterward — only its sha256 hash is stored.
//! 3. Agent uses `Authorization: Bearer <token>` like the master key.
//! Endpoints that have been scope-wired check the agent's role
//! grants the required scope; if not, 403.
//! 4. Operator can revoke any key from the same UI; revoked tokens
//! stop working immediately.
//!
//! The master `admin_api_key` always works on every endpoint. Scoped keys
//! work only on endpoints that have been migrated to call `require_scope`
//! instead of `require_admin`. Endpoints not yet migrated reject scoped
//! keys with 403 — secure-by-default.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, State},
http::{header, HeaderMap},
Json,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use uuid::Uuid;
/// Roles an operator can grant to a scoped API key.
///
/// Each role expands to a static set of scopes at auth time. Adding a
/// new role requires a migration check-constraint update plus a new arm
/// here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// Every `:read` scope. Cannot mutate anything.
ReadOnly,
/// Read-only + license writes. Can issue / revoke / suspend / change
/// tier on licenses, but can't touch products, policies, or codes.
/// Right shape for an automation that gives out comp licenses.
LicenseIssuer,
/// License-issuer + subscription cancellation + machine deactivation.
/// Right shape for a customer-support agent that resolves common
/// requests without touching catalog or settings.
Support,
/// Every scope. Equivalent to the master `admin_api_key` for endpoints
/// that use `require_scope`; still rejected by endpoints that gate on
/// settings-write or tier-write where the master key is required.
FullAdmin,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::ReadOnly => "read-only",
Role::LicenseIssuer => "license-issuer",
Role::Support => "support",
Role::FullAdmin => "full-admin",
}
}
pub fn parse(s: &str) -> Option<Role> {
match s {
"read-only" => Some(Role::ReadOnly),
"license-issuer" => Some(Role::LicenseIssuer),
"support" => Some(Role::Support),
"full-admin" => Some(Role::FullAdmin),
_ => None,
}
}
/// Returns true if this role grants the named scope. Scope names are
/// `<resource>:<read|write>`, e.g. `licenses:write`.
pub fn grants(self, scope: &str) -> bool {
match self {
Role::FullAdmin => true,
Role::ReadOnly => scope.ends_with(":read"),
Role::LicenseIssuer => {
scope.ends_with(":read")
|| matches!(scope, "licenses:write")
}
Role::Support => {
scope.ends_with(":read")
|| matches!(
scope,
"licenses:write"
| "subscriptions:write"
| "machines:write"
)
}
}
}
}
/// Verify the request carries a credential that grants the named scope.
/// Order of acceptance:
/// 1. Master `admin_api_key` — always passes.
/// 2. Scoped API key whose role grants `scope`.
///
/// Returns the actor hash (sha256 of the token) for audit purposes. On
/// failure, 401 if no bearer header, 403 if the token is wrong or lacks
/// the scope.
pub async fn require_scope(
state: &AppState,
headers: &HeaderMap,
scope: &str,
) -> AppResult<String> {
let header_val = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let token = header_val
.strip_prefix("Bearer ")
.ok_or(AppError::Unauthorized)?;
// Master admin key — constant-time compare against the configured value.
if bool::from(
token
.as_bytes()
.ct_eq(state.config.admin_api_key.as_bytes()),
) {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
return Ok(hex::encode(hasher.finalize()));
}
// Scoped API key — hash the candidate, look up, verify not revoked,
// confirm role grants the scope.
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let token_hash = hex::encode(hasher.finalize());
let row: Option<(String, String, Option<String>)> = sqlx::query_as(
"SELECT id, role, revoked_at FROM scoped_api_keys WHERE token_hash = ?",
)
.bind(&token_hash)
.fetch_optional(&state.db)
.await?;
let (key_id, role_str, revoked_at) = match row {
Some(r) => r,
None => return Err(AppError::Forbidden),
};
if revoked_at.is_some() {
return Err(AppError::Forbidden);
}
let role = Role::parse(&role_str).ok_or(AppError::Forbidden)?;
if !role.grants(scope) {
return Err(AppError::Forbidden);
}
// Best-effort touch. Ignored on failure (clock skew, lock contention).
let now = Utc::now().to_rfc3339();
let _ = sqlx::query("UPDATE scoped_api_keys SET last_used_at = ? WHERE id = ?")
.bind(&now)
.bind(&key_id)
.execute(&state.db)
.await;
Ok(token_hash)
}
// ---------- CRUD endpoints (gated on master admin only) ----------
#[derive(Debug, Deserialize)]
pub struct CreateApiKeyReq {
pub label: String,
pub role: String,
}
#[derive(Debug, Serialize)]
pub struct CreateApiKeyResp {
pub id: String,
pub label: String,
pub role: String,
pub created_at: String,
/// The raw token. Returned ONCE on create and never again — operator
/// must copy it now or generate a new key.
pub token: String,
}
/// `POST /v1/admin/api-keys` — generate a new scoped key. Master-only.
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateApiKeyReq>,
) -> AppResult<Json<CreateApiKeyResp>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let label = req.label.trim();
if label.is_empty() || label.len() > 80 {
return Err(AppError::BadRequest(
"label is required and must be at most 80 characters".into(),
));
}
let role = Role::parse(req.role.trim()).ok_or_else(|| {
AppError::BadRequest(
"role must be one of: read-only, license-issuer, support, full-admin".into(),
)
})?;
// 32 bytes of secure random, base64-url-encoded (no padding) → 43 chars.
// Prefix `ks_` so it's recognizable in logs as a Keysat-style token.
use rand::RngCore;
let mut raw = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut raw);
let token = format!(
"ks_{}",
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, raw)
);
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let token_hash = hex::encode(hasher.finalize());
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO scoped_api_keys (id, label, token_hash, role, created_at)
VALUES (?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(label)
.bind(&token_hash)
.bind(role.as_str())
.bind(&now)
.execute(&state.db)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"api_key.create",
Some("api_key"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "label": label, "role": role.as_str() }),
)
.await;
Ok(Json(CreateApiKeyResp {
id,
label: label.to_string(),
role: role.as_str().to_string(),
created_at: now,
token,
}))
}
#[derive(Debug, Serialize)]
pub struct ApiKeyListEntry {
pub id: String,
pub label: String,
pub role: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub revoked_at: Option<String>,
}
/// `GET /v1/admin/api-keys` — list every key (active + revoked). Master-only.
/// Never returns the raw token — only metadata.
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let rows: Vec<(String, String, String, String, Option<String>, Option<String>)> =
sqlx::query_as(
"SELECT id, label, role, created_at, last_used_at, revoked_at
FROM scoped_api_keys ORDER BY created_at DESC",
)
.fetch_all(&state.db)
.await?;
let out: Vec<ApiKeyListEntry> = rows
.into_iter()
.map(|(id, label, role, created_at, last_used_at, revoked_at)| ApiKeyListEntry {
id,
label,
role,
created_at,
last_used_at,
revoked_at,
})
.collect();
Ok(Json(json!({ "api_keys": out })))
}
/// `DELETE /v1/admin/api-keys/:id` — soft-revoke. Master-only. Idempotent:
/// revoking an already-revoked key returns ok with no state change.
pub async fn revoke(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let now = Utc::now().to_rfc3339();
let rows = sqlx::query(
"UPDATE scoped_api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
)
.bind(&now)
.bind(&id)
.execute(&state.db)
.await?
.rows_affected();
if rows == 0 {
// Either not found, or already revoked. Distinguish for the response.
let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM scoped_api_keys WHERE id = ?")
.bind(&id)
.fetch_optional(&state.db)
.await?;
if exists.is_none() {
return Err(AppError::NotFound(format!("api_key '{id}'")));
}
return Ok(Json(json!({ "ok": true, "already_revoked": true })));
}
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"api_key.revoke",
Some("api_key"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
Ok(Json(json!({ "ok": true, "revoked_at": now })))
}
+1 -1
View File
@@ -532,7 +532,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
</div>
<footer class="kfooter">
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-paid software licensing</span>
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-native self-hosted software licensing</span>
</footer>
<script>
+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 })))
}
+21 -3
View File
@@ -55,7 +55,9 @@
pub mod admin;
pub mod admin_ui;
pub mod api_keys;
pub mod auth;
pub mod openapi;
pub mod btcpay_authorize;
pub mod discount_codes;
pub mod machines;
@@ -325,6 +327,10 @@ pub fn router(state: AppState) -> Router {
"/v1/admin/policies/:id/public",
patch(policies::set_public),
)
.route(
"/v1/admin/policies/:id/archived",
patch(policies::set_archived),
)
.route(
"/v1/admin/policies/:id/tip",
patch(policies::set_tip),
@@ -335,6 +341,14 @@ pub fn router(state: AppState) -> Router {
get(policies::list_public_policies),
)
.route("/v1/admin/tips", get(policies::list_tips))
// Scoped API keys — additional credentials with bounded permissions.
// Master admin_api_key gates the management endpoints; the scoped
// keys themselves are accepted on endpoints that call require_scope.
.route(
"/v1/admin/api-keys",
get(api_keys::list).post(api_keys::create),
)
.route("/v1/admin/api-keys/:id", axum::routing::delete(api_keys::revoke))
// Subscriptions (recurring billing) — admin list + cancel.
.route(
"/v1/admin/subscriptions",
@@ -457,6 +471,10 @@ pub fn router(state: AppState) -> Router {
// Public read of the issuer's signing public key — used by the
// admin Overview "Embed your public key" tip and by SDK consumers.
.route("/v1/issuer/public-key", get(issuer_key::public))
// OpenAPI 3.1 spec — public, no auth. Drives agent discovery and
// SDK code generation. Curated subset of the full route surface;
// see crate::api::openapi for the inline definition.
.route("/v1/openapi.json", get(openapi::spec))
// Tier model — drives the admin sidebar's persistent upgrade banner.
.route("/v1/admin/tier", get(tier::admin_status))
// Web-UI password auth (v0.1.0:28+).
@@ -741,7 +759,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
</div>
<footer class="kfooter">
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-paid software licensing</span>
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-native self-hosted software licensing</span>
</footer>
<script>
@@ -817,8 +835,8 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
function waitingCopy(status) {{
const min = Math.floor(elapsedMs / 60000);
if (status === 'pending' || status === 'processing') {{
if (min < 2) return 'invoice ' + status + ' — should settle within a block (~10 min).';
if (min < 10) return 'invoice ' + status + ' — waiting for block confirmation. Safe to leave this tab open or bookmark this URL and come back.';
if (min < 2) return 'invoice ' + status + ' — Lightning settles in seconds; on-chain takes a block (~10 min).';
if (min < 10) return 'invoice ' + status + ' — looks like an on-chain payment, waiting for block confirmation. Safe to leave this tab open or bookmark this URL.';
return 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.';
}}
return 'invoice status: ' + (status || 'pending');
+438
View File
@@ -0,0 +1,438 @@
//! OpenAPI 3.1 spec for agent / SDK discovery.
//!
//! Served unauthenticated at `GET /v1/openapi.json`. The spec is a curated
//! subset of the daemon's endpoints — not auto-derived from handler
//! signatures today, so consider it a stable agent surface rather than a
//! guarantee that every internal route is documented. Endpoints not in
//! the spec still work the same way for callers that already know about
//! them.
//!
//! Authentication: every `/v1/admin/*` endpoint takes
//! `Authorization: Bearer <token>` where the token is either the master
//! `admin_api_key` or a scoped key generated in the admin UI. Master key
//! works on every endpoint; scoped keys work on endpoints that have been
//! migrated to `require_scope` (see `crate::api::api_keys`).
//!
//! Storage: the spec is held as a static JSON string at the bottom of
//! this file, parsed once into a `serde_json::Value` (via `OnceLock`),
//! and re-served from that cached value on each request. Keeps the
//! `json!` macro recursion limit out of the way.
use axum::Json;
use serde_json::Value;
use std::sync::OnceLock;
static SPEC: OnceLock<Value> = OnceLock::new();
/// `GET /v1/openapi.json` — return the spec. Public, no auth.
pub async fn spec() -> Json<Value> {
let v = SPEC.get_or_init(|| {
serde_json::from_str(SPEC_JSON).expect("OpenAPI spec is valid JSON")
});
Json(v.clone())
}
const SPEC_JSON: &str = r##"{
"openapi": "3.1.0",
"info": {
"title": "Keysat",
"description": "Bitcoin-native self-hosted software licensing service. This spec documents the operator-side admin API plus the buyer-facing validate / purchase / recover endpoints. Authentication: Bearer token. Master admin_api_key works on every endpoint; scoped API keys (generated in Settings → API keys) work on endpoints with bounded scopes.",
"version": "0.2.0",
"contact": { "name": "Keysat", "url": "https://keysat.xyz" }
},
"servers": [
{ "url": "https://licensing.keysat.xyz", "description": "Keysat's master instance" },
{ "url": "https://{your-keysat-host}", "description": "Your own Keysat instance" }
],
"security": [ { "bearerAuth": [] } ],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"description": "Master admin_api_key OR a scoped API key (ks_...). Scoped keys are gated on a role: read-only, license-issuer, support, or full-admin."
}
},
"schemas": {
"Error": {
"type": "object",
"properties": {
"error": { "type": "string", "description": "Stable machine-readable error code (e.g. tier_cap, license_revoked, not_found)" },
"message": { "type": "string", "description": "Human-readable detail; safe to surface to operators" },
"upgrade_url": { "type": "string", "description": "Present on 402 tier-cap errors", "nullable": true }
},
"required": ["error"]
},
"License": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"product_id": { "type": "string", "format": "uuid" },
"product_slug": { "type": "string" },
"policy_id": { "type": "string", "format": "uuid", "nullable": true },
"buyer_email": { "type": "string", "nullable": true },
"issued_at": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time", "nullable": true },
"status": { "type": "string", "enum": ["active", "revoked", "suspended"] },
"max_machines": { "type": "integer" },
"entitlements": { "type": "array", "items": { "type": "string" } },
"license_key": { "type": "string", "description": "The LIC1... bearer credential. Returned on issue / recover only; never on list." }
}
},
"Product": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"slug": { "type": "string" },
"name": { "type": "string" },
"description": { "type": "string" },
"price_sats": { "type": "integer", "nullable": true },
"price_currency": { "type": "string", "enum": ["SAT", "USD", "EUR"], "nullable": true },
"price_value": { "type": "integer", "nullable": true },
"active": { "type": "boolean" },
"entitlements_catalog": {
"type": "array",
"nullable": true,
"items": {
"type": "object",
"properties": {
"slug": { "type": "string" },
"name": { "type": "string" },
"description": { "type": "string" }
}
}
}
}
},
"Policy": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"product_id": { "type": "string", "format": "uuid" },
"slug": { "type": "string" },
"name": { "type": "string" },
"duration_seconds": { "type": "integer", "description": "0 = perpetual" },
"max_machines": { "type": "integer" },
"is_trial": { "type": "boolean" },
"price_sats_override": { "type": "integer", "nullable": true },
"entitlements": { "type": "array", "items": { "type": "string" } },
"active": { "type": "boolean" },
"public": { "type": "boolean" },
"is_recurring": { "type": "boolean" },
"renewal_period_days": { "type": "integer" },
"trial_days": { "type": "integer" },
"tier_rank": { "type": "integer", "nullable": true },
"archived_at": { "type": "string", "format": "date-time", "nullable": true }
}
},
"ValidateResponse": {
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"reason": { "type": "string", "description": "Machine-readable; one of: bad_signature, not_found, revoked, suspended, expired, fingerprint_mismatch, product_mismatch, machine_cap_exceeded" },
"license_id": { "type": "string", "nullable": true },
"product_slug": { "type": "string", "nullable": true },
"policy_slug": { "type": "string", "nullable": true },
"expires_at": { "type": "string", "format": "date-time", "nullable": true },
"entitlements": { "type": "array", "items": { "type": "string" } }
}
}
}
},
"paths": {
"/v1/openapi.json": {
"get": {
"summary": "This spec",
"description": "Serves the OpenAPI 3.1 spec. Public, no auth.",
"security": [],
"responses": { "200": { "description": "The spec." } }
}
},
"/v1/issuer/public-key": {
"get": {
"summary": "Get the daemon's signing public key",
"description": "Returns the PEM-encoded Ed25519 public key the daemon uses to sign licenses. Public, no auth. SDK consumers can embed this for offline verification.",
"security": [],
"responses": {
"200": {
"description": "Public key",
"content": { "application/json": { "schema": {
"type": "object",
"properties": { "public_key_pem": { "type": "string" } }
} } }
}
}
}
},
"/v1/validate": {
"post": {
"summary": "Validate a license key",
"description": "Buyer-facing endpoint called by SDKs at app boot. Verifies signature, checks revocation/suspension/expiry, and (when product_slug is supplied) refuses keys issued for a different product. Always returns 200; ok=false with a stable reason on rejection.",
"security": [],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"key": { "type": "string", "description": "The LIC1... license key" },
"product_slug": { "type": "string", "description": "When supplied, the daemon refuses keys issued for a different product. Recommended." },
"fingerprint": { "type": "string", "description": "Machine fingerprint for cap enforcement. SHA-256 hashed daemon-side." },
"hostname": { "type": "string" },
"platform": { "type": "string" }
},
"required": ["key"]
} } }
},
"responses": {
"200": { "description": "Validation result", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidateResponse" } } } }
}
}
},
"/v1/products/{slug}/policies": {
"get": {
"summary": "List a product's public tiers",
"description": "Buyer-facing tier listing — same data /buy/<slug> renders. Use this in your app's in-app tier picker. Public, no auth.",
"security": [],
"parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } ],
"responses": { "200": { "description": "Tier list" } }
}
},
"/v1/purchase": {
"post": {
"summary": "Start a buyer purchase",
"description": "Opens an invoice with the active payment provider. The buyer opens the returned checkout_url; once payment settles, the license is available via /v1/purchase/{invoice_id} or the corresponding webhook.",
"security": [],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"product": { "type": "string", "description": "Product slug" },
"policy_slug": { "type": "string", "description": "Optional. Specifies which tier; falls back to the product's default policy." },
"buyer_email": { "type": "string" },
"redirect_url": { "type": "string" },
"code": { "type": "string", "description": "Optional discount code" }
},
"required": ["product"]
} } }
},
"responses": { "200": { "description": "Purchase session created" } }
}
},
"/v1/purchase/{invoice_id}": {
"get": {
"summary": "Poll for license issuance",
"description": "Polled by the buyer's app until the license is issued (status=settled and license_key present). Public, no auth.",
"security": [],
"parameters": [ { "name": "invoice_id", "in": "path", "required": true, "schema": { "type": "string" } } ],
"responses": { "200": { "description": "Current invoice status" } }
}
},
"/v1/upgrade-quote": {
"post": {
"summary": "Quote a tier upgrade",
"description": "Buyer-facing: given a license key and a target policy slug, compute the proration charge. No DB writes. Auth is by signed license_key in the body.",
"security": [],
"responses": { "200": { "description": "Quote" } }
}
},
"/v1/upgrade": {
"post": {
"summary": "Start a tier upgrade",
"description": "Creates an invoice for the prorated charge. On settle, the license's entitlements + expiry flip to the target tier without rotating the license key.",
"security": [],
"responses": { "200": { "description": "Upgrade invoice started" } }
}
},
"/v1/subscriptions/cancel": {
"post": {
"summary": "Buyer self-service subscription cancellation",
"description": "Cancels recurring renewals on the subscription tied to this license. Auth by signed license_key in the body. License stays valid through current cycle's expires_at.",
"security": [],
"responses": { "200": { "description": "Cancelled" } }
}
},
"/v1/recover": {
"post": {
"summary": "Recover a lost license key",
"description": "Given (invoice_id, email), returns the license_key for that purchase. Generic 404 on any mismatch. Rate-limited 10/min/IP.",
"security": [],
"responses": { "200": { "description": "License" } }
}
},
"/v1/admin/licenses": {
"get": {
"summary": "List licenses",
"description": "Scope required: `licenses:read`. Filter by status, product_slug, buyer_email, expiring soon, etc. via query params.",
"responses": { "200": { "description": "License list" } }
},
"post": {
"summary": "Issue a license manually",
"description": "Scope required: `licenses:write`. Mints a fresh license without going through purchase. Useful for comping, manual support workflows.",
"responses": { "200": { "description": "Issued license" } }
}
},
"/v1/admin/licenses/{id}/revoke": {
"post": {
"summary": "Revoke a license",
"description": "Scope required: `licenses:write`. Idempotent. Online validate calls immediately return reason=revoked.",
"responses": { "200": { "description": "Revoked" } }
}
},
"/v1/admin/licenses/{id}/suspend": {
"post": {
"summary": "Suspend a license",
"description": "Scope required: `licenses:write`. Like revoke but reversible (see /unsuspend).",
"responses": { "200": { "description": "Suspended" } }
}
},
"/v1/admin/licenses/{id}/unsuspend": {
"post": {
"summary": "Unsuspend a license",
"description": "Scope required: `licenses:write`. Reverses suspend.",
"responses": { "200": { "description": "Unsuspended" } }
}
},
"/v1/admin/licenses/{id}/change-tier": {
"post": {
"summary": "Admin tier change (comp)",
"description": "Scope required: `licenses:write`. Always applies as a comp from the admin path — no invoice. Use for support workflows where a buyer should get a different tier without payment.",
"responses": { "200": { "description": "Tier changed" } }
}
},
"/v1/admin/products": {
"get": {
"summary": "List products",
"description": "Scope required: `products:read`.",
"responses": { "200": { "description": "Product list" } }
},
"post": {
"summary": "Create a product",
"description": "Scope required: `products:write`.",
"responses": { "200": { "description": "Created" }, "402": { "description": "tier_cap — Creator tier limited to 5 products" } }
}
},
"/v1/admin/policies": {
"get": {
"summary": "List policies",
"description": "Scope required: `policies:read`. Filter by product_slug. Include archived with include_archived=true.",
"responses": { "200": { "description": "Policy list" } }
},
"post": {
"summary": "Create a policy (tier)",
"description": "Scope required: `policies:write`. Recurring policies require the `recurring_billing` self-tier entitlement.",
"responses": { "200": { "description": "Created" } }
}
},
"/v1/admin/policies/{id}/archived": {
"patch": {
"summary": "Archive or unarchive a policy",
"description": "Scope required: `policies:write`. Soft-archive: hides from admin grid and buy page, refuses new purchases + renewals. Existing licenses keep validating.",
"responses": { "200": { "description": "Toggled" } }
}
},
"/v1/admin/subscriptions": {
"get": {
"summary": "List subscriptions",
"description": "Scope required: `subscriptions:read`. Filter by status.",
"responses": { "200": { "description": "Subscription list" } }
}
},
"/v1/admin/subscriptions/{id}/cancel": {
"post": {
"summary": "Admin cancel a subscription",
"description": "Scope required: `subscriptions:write`. License stays valid through end of current cycle.",
"responses": { "200": { "description": "Cancelled" } }
}
},
"/v1/admin/machines": {
"get": {
"summary": "List machines",
"description": "Scope required: `machines:read`. One row per (license_id, fingerprint) seen by /v1/validate.",
"responses": { "200": { "description": "Machine list" } }
}
},
"/v1/admin/machines/{id}/deactivate": {
"post": {
"summary": "Force-deactivate a machine",
"description": "Scope required: `machines:write`. Frees the seat under that license. Validate calls from that fingerprint get fingerprint_mismatch.",
"responses": { "200": { "description": "Deactivated" } }
}
},
"/v1/admin/discount-codes": {
"get": {
"summary": "List discount codes",
"description": "Scope required: `codes:read`.",
"responses": { "200": { "description": "Code list" } }
},
"post": {
"summary": "Create a discount code",
"description": "Scope required: `codes:write`. Creator tier caps at 10 active codes.",
"responses": { "200": { "description": "Created" } }
}
},
"/v1/admin/webhook-endpoints": {
"get": {
"summary": "List webhook endpoints",
"description": "Scope required: `webhooks:read`.",
"responses": { "200": { "description": "Endpoint list" } }
},
"post": {
"summary": "Create a webhook endpoint",
"description": "Scope required: `webhooks:write`. URL + secret + event filter. Outbound deliveries are HMAC-SHA256 signed.",
"responses": { "200": { "description": "Created" } }
}
},
"/v1/admin/api-keys": {
"get": {
"summary": "List scoped API keys",
"description": "Master admin key required. Never returns the raw token.",
"responses": { "200": { "description": "Key metadata list" } }
},
"post": {
"summary": "Create a scoped API key",
"description": "Master admin key required. Token returned ONCE in the response.",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"label": { "type": "string", "description": "Operator-friendly name, e.g. 'Recap support bot'" },
"role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "full-admin"] }
},
"required": ["label", "role"]
} } }
},
"responses": { "200": { "description": "Created with raw token (returned once)" } }
}
},
"/v1/admin/api-keys/{id}": {
"delete": {
"summary": "Revoke a scoped API key",
"description": "Master admin key required. Soft-revoke; rows are kept for audit. Idempotent.",
"responses": { "200": { "description": "Revoked" } }
}
},
"/v1/admin/tier": {
"get": {
"summary": "Get this daemon's tier + usage + caps",
"description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier.",
"responses": { "200": { "description": "Tier info" } }
}
}
}
}"##;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spec_json_parses() {
let v: Value = serde_json::from_str(SPEC_JSON).expect("spec parses as JSON");
// Sanity checks: top-level openapi field, at least one path, at least one schema.
assert_eq!(v.get("openapi").and_then(|x| x.as_str()), Some("3.1.0"));
assert!(v.get("paths").and_then(|p| p.as_object()).map(|m| !m.is_empty()).unwrap_or(false));
assert!(v.pointer("/components/schemas/License").is_some());
}
}
@@ -99,6 +99,7 @@ pub async fn activate(
state.set_payment_provider(provider).await;
}
ProviderKind::Zaprite => {
crate::api::tier::enforce_zaprite_feature(&state).await?;
let cfg = payment::zaprite::config::load(&state.db)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e:#}")))?
+147 -51
View File
@@ -277,6 +277,11 @@ pub struct ListPoliciesQuery {
pub product_slug: String,
#[serde(default)]
pub include_inactive: bool,
/// When true, archived policies (those with a non-null `archived_at`)
/// are included. Default false — admin grid hides archived unless the
/// "Show archived" toggle is on.
#[serde(default)]
pub include_archived: bool,
}
pub async fn list(
@@ -288,7 +293,13 @@ pub async fn list(
let product = repo::get_product_by_slug(&state.db, &q.product_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product_slug)))?;
let rows = repo::list_policies_by_product(&state.db, &product.id, !q.include_inactive).await?;
let rows = repo::list_policies_by_product_with_archived(
&state.db,
&product.id,
!q.include_inactive,
q.include_archived,
)
.await?;
Ok(Json(json!({ "policies": rows })))
}
@@ -321,6 +332,43 @@ pub async fn set_active(
Ok(Json(json!({ "ok": true })))
}
#[derive(Debug, Deserialize)]
pub struct SetArchivedReq {
pub archived: bool,
}
/// PATCH `/v1/admin/policies/:id/archived` — toggle the soft-archive flag.
///
/// Archived policies are hidden from the admin grid (unless "Show archived"
/// is on) and from the public `/buy/<slug>` page. Existing licenses keep
/// validating because their entitlements are signed into the key. Active
/// recurring subscriptions tied to an archived policy will stop renewing
/// (renewal worker treats archived as a hard stop and surfaces a clear
/// event in the audit log).
pub async fn set_archived(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetArchivedReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_policy_archived(&state.db, &id, req.archived).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
if req.archived { "policy.archive" } else { "policy.unarchive" },
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "archived": req.archived }),
)
.await;
Ok(Json(json!({ "ok": true, "archived": req.archived })))
}
#[derive(Debug, Deserialize)]
pub struct PolicyDeleteOpts {
#[serde(default)]
@@ -348,6 +396,7 @@ pub async fn delete(
.await?
.ok_or_else(|| AppError::NotFound(format!("policy '{id}'")))?;
// Total counts (for cascade reporting).
let invoice_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE policy_id = ?")
.bind(&id)
@@ -358,64 +407,111 @@ pub async fn delete(
.bind(&id)
.fetch_one(&state.db)
.await?;
if !opts.force && invoice_count + license_count > 0 {
// "Live" references that would actually block a safe-delete: a
// non-revoked license, a settled invoice (real audit history), or
// an active/past_due subscription. Revoked-license tombstones and
// non-settled invoices (pending/expired/invalid) are dead weight
// that the safe-delete can sweep up — they hold no operator value.
let live_license_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM licenses
WHERE policy_id = ? AND COALESCE(status,'active') != 'revoked'",
)
.bind(&id)
.fetch_one(&state.db)
.await?;
let settled_invoice_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM invoices WHERE policy_id = ? AND status = 'settled'",
)
.bind(&id)
.fetch_one(&state.db)
.await?;
let active_sub_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM subscriptions
WHERE policy_id = ? AND status IN ('active', 'past_due')",
)
.bind(&id)
.fetch_one(&state.db)
.await?;
if !opts.force && (live_license_count + settled_invoice_count + active_sub_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
"cannot delete policy '{}' — it has {} live license(s), {} settled invoice(s), \
and {} active subscription(s) referencing it. Archive it to hide it from \
the admin grid and the buy page, revoke any outstanding licenses to free \
the safe-delete path, or use ?force=true to cascade through everything.",
policy.slug, live_license_count, settled_invoice_count, active_sub_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
};
// Even in safe-delete mode we cascade through tombstones (revoked
// licenses, dead invoices, machines/redemptions tied to them) since
// the operator has signalled intent to fully delete. Compute the
// counts for the audit log either way.
let machine_count: i64 = 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?;
let redemption_count: i64 = 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?;
// Cascade order matters — children before parents to satisfy FKs.
// Safe-delete + force-delete share the cascade body now; the only
// difference is the eligibility check above.
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 = ?)",
)
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?;
// tier_changes references both from_policy_id + to_policy_id — wipe
// any row touching this policy on either side.
sqlx::query(
"DELETE FROM tier_changes WHERE from_policy_id = ? OR to_policy_id = ?",
)
.bind(&id)
.bind(&id)
.execute(&mut *tx)
.await?;
// discount_codes.applies_to_policy_id references this policy. Null
// it out rather than delete the code — codes can target multiple
// policies in future and surviving codes are useful audit material.
sqlx::query(
"UPDATE discount_codes SET applies_to_policy_id = NULL
WHERE applies_to_policy_id = ?",
)
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM subscriptions 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 = ?)",
)
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 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)
@@ -435,8 +531,8 @@ pub async fn delete(
"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_licenses": license_count,
"cascaded_invoices": invoice_count,
"cascaded_machines": machine_count,
"cascaded_redemptions": redemption_count,
}),
@@ -446,8 +542,8 @@ pub async fn delete(
"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_licenses": license_count,
"cascaded_invoices": invoice_count,
"cascaded_machines": machine_count,
"cascaded_redemptions": redemption_count,
})))
+3 -9
View File
@@ -1,7 +1,7 @@
//! Admin endpoints for managing the daemon's own self-license
//! (Keysat-licenses-Keysat).
//!
//! - `GET /v1/admin/self-license` — current tier (licensed / unlicensed)
//! - `GET /v1/admin/self-license` — current tier (licensed / Creator)
//! - `POST /v1/admin/self-license` — activate a new license. Validates
//! against the embedded master pubkey, writes the file to
//! `SELF_LICENSE_PATH`, and swaps the runtime tier in app state.
@@ -12,6 +12,8 @@
use crate::api::AppState;
use crate::error::AppResult;
use crate::license_self::{self, Tier};
// `license_self::mode` was removed when enforce mode was retired; this
// module retains its own `Tier` re-export for the admin response shape.
use axum::{
extract::State,
http::StatusCode,
@@ -25,7 +27,6 @@ use serde::{Deserialize, Serialize};
pub enum TierStatus {
Unlicensed {
reason: String,
mode: &'static str,
},
Licensed {
license_id: String,
@@ -33,19 +34,13 @@ pub enum TierStatus {
/// Unix seconds; 0 means perpetual.
expires_at: i64,
entitlements: Vec<String>,
mode: &'static str,
},
}
fn tier_to_status(tier: &Tier) -> TierStatus {
let mode = match license_self::mode() {
license_self::Mode::Permissive => "permissive",
license_self::Mode::Enforce => "enforce",
};
match tier {
Tier::Unlicensed { reason } => TierStatus::Unlicensed {
reason: reason.clone(),
mode,
},
Tier::Licensed {
license_id,
@@ -57,7 +52,6 @@ fn tier_to_status(tier: &Tier) -> TierStatus {
product_id: product_id.to_string(),
expires_at: *expires_at,
entitlements: entitlements.clone(),
mode,
},
}
}
+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
@@ -49,6 +49,7 @@ pub async fn connect(
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();
+150 -10
View File
@@ -838,7 +838,7 @@ const POLICY_COLS: &str = "id, product_id, name, slug, duration_seconds, grace_s
max_machines, is_trial, price_sats_override,
entitlements_json, metadata_json, active, public,
is_recurring, renewal_period_days, grace_period_days, trial_days,
tier_rank,
tier_rank, archived_at,
created_at, updated_at";
/// Bundles the recurring-subscription knobs so we don't keep growing
@@ -962,25 +962,43 @@ pub async fn list_policies_by_product(
product_id: &str,
only_active: bool,
) -> AppResult<Vec<Policy>> {
let sql = if only_active {
format!("SELECT {POLICY_COLS} FROM policies WHERE product_id = ? AND active = 1 ORDER BY name")
} else {
format!("SELECT {POLICY_COLS} FROM policies WHERE product_id = ? ORDER BY name")
};
list_policies_by_product_with_archived(pool, product_id, only_active, false).await
}
/// Variant of `list_policies_by_product` that lets the caller include
/// archived rows. Admin UI passes `include_archived = true` when the
/// "Show archived" toggle is on.
pub async fn list_policies_by_product_with_archived(
pool: &SqlitePool,
product_id: &str,
only_active: bool,
include_archived: bool,
) -> AppResult<Vec<Policy>> {
let mut clauses: Vec<&str> = vec!["product_id = ?"];
if only_active {
clauses.push("active = 1");
}
if !include_archived {
clauses.push("archived_at IS NULL");
}
let where_clause = clauses.join(" AND ");
let sql = format!(
"SELECT {POLICY_COLS} FROM policies WHERE {where_clause} ORDER BY name"
);
let rows = sqlx::query(&sql).bind(product_id).fetch_all(pool).await?;
Ok(rows.into_iter().map(row_to_policy).collect())
}
/// Public-buyer view: only active+public policies. Sorted by ascending
/// effective price so the cheapest tier renders leftmost. The buy page
/// is the only caller; admin should use `list_policies_by_product`.
/// Public-buyer view: only active+public+non-archived policies. Sorted by
/// ascending effective price so the cheapest tier renders leftmost. The
/// buy page is the only caller; admin should use `list_policies_by_product`.
pub async fn list_public_policies_by_product(
pool: &SqlitePool,
product_id: &str,
) -> AppResult<Vec<Policy>> {
let sql = format!(
"SELECT {POLICY_COLS} FROM policies
WHERE product_id = ? AND active = 1 AND public = 1
WHERE product_id = ? AND active = 1 AND public = 1 AND archived_at IS NULL
ORDER BY COALESCE(price_sats_override, 0) ASC, name ASC"
);
let rows = sqlx::query(&sql).bind(product_id).fetch_all(pool).await?;
@@ -1154,6 +1172,28 @@ pub async fn set_policy_active(pool: &SqlitePool, id: &str, active: bool) -> App
Ok(())
}
/// Soft-archive a policy. Idempotent: re-archiving stamps a new
/// timestamp without erroring. Pass `archived = false` to un-archive.
pub async fn set_policy_archived(
pool: &SqlitePool,
id: &str,
archived: bool,
) -> AppResult<()> {
let now = Utc::now().to_rfc3339();
let archived_at: Option<&str> = if archived { Some(now.as_str()) } else { None };
let rows = sqlx::query("UPDATE policies SET archived_at = ?, updated_at = ? WHERE id = ?")
.bind(archived_at)
.bind(&now)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("policy {id}")));
}
Ok(())
}
fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
let entitlements_json: String = row.get("entitlements_json");
let entitlements: Vec<String> =
@@ -1181,6 +1221,12 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
.try_get::<Option<i64>, _>("tier_rank")
.ok()
.flatten();
// archived_at lands in migration 0015. Same pattern as tier_rank —
// nullable column, fall back to None if missing entirely.
let archived_at: Option<String> = row
.try_get::<Option<String>, _>("archived_at")
.ok()
.flatten();
Policy {
id: row.get("id"),
product_id: row.get("product_id"),
@@ -1203,6 +1249,7 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
grace_period_days,
trial_days,
tier_rank,
archived_at,
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}
@@ -1371,6 +1418,99 @@ pub async fn list_all_machines(pool: &SqlitePool, license_id: &str) -> AppResult
Ok(rows.into_iter().map(row_to_machine).collect())
}
/// One row of the admin Machines tab. Joins `machines` against
/// `licenses` + `products` so the operator sees buyer_email + product
/// slug + status without an N+1 fetch from the client.
#[derive(Debug, serde::Serialize)]
pub struct MachineEnriched {
pub id: String,
pub license_id: String,
pub product_id: String,
pub product_slug: String,
pub product_name: String,
pub buyer_email: Option<String>,
pub license_status: String,
pub hostname: Option<String>,
pub platform: Option<String>,
pub ip_last_seen: Option<String>,
pub activated_at: String,
pub last_heartbeat_at: String,
pub deactivated_at: Option<String>,
pub deactivation_reason: Option<String>,
pub active: bool,
}
/// Global admin list with optional filters. Used by the Machines tab to
/// render every machine across every license — grouped client-side by
/// product. Filters are optional, all conjunctive (AND).
///
/// - `product_id`: scope to a single product
/// - `license_id`: scope to a single license (used by drill-down view)
/// - `include_inactive`: include rows where deactivated_at IS NOT NULL
/// - `limit`: cap result size; default 500 (admin grid is paginated UX-side)
pub async fn list_machines_admin(
pool: &SqlitePool,
product_id: Option<&str>,
license_id: Option<&str>,
include_inactive: bool,
limit: i64,
) -> AppResult<Vec<MachineEnriched>> {
let mut sql = String::from(
"SELECT m.id, m.license_id, l.product_id, p.slug AS product_slug, p.name AS product_name,
l.buyer_email, l.status AS license_status,
m.hostname, m.platform, m.ip_last_seen,
m.activated_at, m.last_heartbeat_at,
m.deactivated_at, m.deactivation_reason
FROM machines m
JOIN licenses l ON l.id = m.license_id
JOIN products p ON p.id = l.product_id
WHERE 1=1",
);
if !include_inactive {
sql.push_str(" AND m.deactivated_at IS NULL");
}
if product_id.is_some() {
sql.push_str(" AND l.product_id = ?");
}
if license_id.is_some() {
sql.push_str(" AND m.license_id = ?");
}
sql.push_str(" ORDER BY m.last_heartbeat_at DESC LIMIT ?");
let mut q = sqlx::query(&sql);
if let Some(v) = product_id {
q = q.bind(v);
}
if let Some(v) = license_id {
q = q.bind(v);
}
q = q.bind(limit);
let rows = q.fetch_all(pool).await?;
let out = rows
.into_iter()
.map(|row| {
let deactivated_at: Option<String> = row.get("deactivated_at");
MachineEnriched {
id: row.get("id"),
license_id: row.get("license_id"),
product_id: row.get("product_id"),
product_slug: row.get("product_slug"),
product_name: row.get("product_name"),
buyer_email: row.get("buyer_email"),
license_status: row.get("license_status"),
hostname: row.get("hostname"),
platform: row.get("platform"),
ip_last_seen: row.get("ip_last_seen"),
activated_at: row.get("activated_at"),
last_heartbeat_at: row.get("last_heartbeat_at"),
active: deactivated_at.is_none(),
deactivated_at,
deactivation_reason: row.get("deactivation_reason"),
}
})
.collect();
Ok(out)
}
pub async fn get_active_machine_by_fp(
pool: &SqlitePool,
license_id: &str,
+30 -86
View File
@@ -7,22 +7,20 @@
//! customer licenses, and verify its signature against the master
//! public key.
//!
//! Two modes:
//! - `Permissive` (default for dev builds): missing or invalid
//! licenses log a warning and the daemon starts in
//! `Tier::Unlicensed`. No features are gated yet — that's a
//! future v0.2.x flip.
//! - `Enforce`: missing or invalid licenses cause the daemon to
//! refuse to start. Set at compile time via the
//! `KEYSAT_LICENSE_ENFORCE=1` env var. Marketplace builds set
//! this; local dev builds don't.
//! Missing or invalid self-licenses log a warning and the daemon starts in
//! `Tier::Unlicensed`, which the admin UI labels "Creator" — the free tier
//! with the Creator caps applied (5 products, 5 policies per product, 10
//! active codes). The daemon is always functional out of the box; paying
//! lifts the caps and unlocks `recurring_billing` + `zaprite_payments`.
//!
//! The master pubkey is the *public* half of an Ed25519 keypair held
//! offline by the keysat.xyz team. It is not secret — embedding it in
//! source on GitHub is fine. Anyone with the *private* half can mint
//! Keysat self-licenses; the private half lives on paper backup +
//! hardware-token storage and never touches a connected machine
//! except briefly when a master Keysat instance is being initialized.
//! The master pubkey is the *public* half of an Ed25519 keypair held by
//! the operator who issues Keysat-product licenses. It is not secret —
//! embedding it in source on GitHub is fine. Anyone with the *private*
//! half can mint Keysat self-licenses. On the master Keysat instance
//! that owner runs, the private half doubles as the per-instance
//! license-signing key (stored in the `server_keys` table); on every
//! other Keysat install the private half doesn't exist and the daemon
//! only ever verifies, never signs.
use crate::crypto::{parse_key, verify_payload};
use anyhow::{bail, Context, Result};
@@ -45,28 +43,12 @@ MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA=
/// persistent data volume so it survives package upgrades.
pub const SELF_LICENSE_PATH: &str = "/data/keysat-license.txt";
/// Build-time enforcement toggle. `KEYSAT_LICENSE_ENFORCE=1` at
/// `cargo build` time enables enforce mode.
const ENFORCE_FLAG: Option<&str> = option_env!("KEYSAT_LICENSE_ENFORCE");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Missing/invalid license logs a warning and continues. Default.
Permissive,
/// Missing/invalid license refuses to start the daemon.
Enforce,
}
pub fn mode() -> Mode {
match ENFORCE_FLAG {
Some("1") | Some("true") | Some("yes") => Mode::Enforce,
_ => Mode::Permissive,
}
}
#[derive(Debug, Clone)]
pub enum Tier {
/// No license configured, or license verify failed in permissive mode.
/// No self-license file, or verify failed. Surfaces as "Creator"
/// in the admin UI — the free tier with the Creator caps applied.
/// `reason` is for logs and the admin `/v1/admin/tier` payload, not
/// shown to end users.
Unlicensed { reason: String },
/// Valid license verified against the trust-root.
Licensed {
@@ -79,34 +61,30 @@ pub enum Tier {
}
impl Tier {
/// String form for log / metrics labels. `Unlicensed` surfaces as
/// "creator" since that's how the admin UI presents it — operators
/// see one consistent name across logs and dashboard.
pub fn as_str(&self) -> &'static str {
match self {
Tier::Unlicensed { .. } => "unlicensed",
Tier::Unlicensed { .. } => "creator",
Tier::Licensed { .. } => "licensed",
}
}
}
/// Boot-time check. In permissive mode this always returns `Ok`; in
/// enforce mode it returns `Err` on missing / invalid / expired
/// licenses, which causes `main` to bail out before we open any
/// network sockets.
/// Boot-time check. Always returns `Ok` — Keysat boots into the Creator
/// (free) tier when no valid self-license is present, never refuses to
/// start. Logs a one-line info or warn line for operator visibility.
pub fn check_at_boot() -> Result<Tier> {
let mode = mode();
tracing::info!(
mode = mode.as_str(),
"Keysat self-license check (mode={})",
mode.as_str()
);
let license_str = match read_license_string() {
Some(s) => s,
None => {
let reason = format!(
"no license at {} or KEYSAT_LICENSE env var",
"no license at {} or KEYSAT_LICENSE env var; running Creator (free) tier",
SELF_LICENSE_PATH
);
return handle_missing_or_invalid(mode, reason, None);
tracing::info!(tier = "creator", "Keysat self-license: {}", reason);
return Ok(Tier::Unlicensed { reason });
}
};
@@ -116,37 +94,12 @@ pub fn check_at_boot() -> Result<Tier> {
Ok(tier)
}
Err(e) => {
let reason = format!("verification failed: {e:#}");
handle_missing_or_invalid(mode, reason, Some(e))
}
}
}
fn handle_missing_or_invalid(
mode: Mode,
reason: String,
err: Option<anyhow::Error>,
) -> Result<Tier> {
match mode {
Mode::Permissive => {
tracing::warn!(
tier = "unlicensed",
"Keysat self-license: {} — running unlicensed (permissive build)",
reason
let reason = format!(
"verification failed: {e:#} — falling back to Creator (free) tier"
);
tracing::warn!(tier = "creator", "Keysat self-license: {}", reason);
Ok(Tier::Unlicensed { reason })
}
Mode::Enforce => {
tracing::error!(
"Keysat self-license: {} — refusing to start. \
Activate via StartOS → Keysat → Actions → Activate Keysat license.",
reason
);
match err {
Some(e) => Err(e.context("self-license invalid (enforce mode)")),
None => bail!("self-license missing (enforce mode): {reason}"),
}
}
}
}
@@ -246,15 +199,6 @@ fn log_licensed(tier: &Tier) {
}
}
impl Mode {
fn as_str(self) -> &'static str {
match self {
Mode::Permissive => "permissive",
Mode::Enforce => "enforce",
}
}
}
/// Live-refresh the daemon's self-tier from the local `licenses` row.
///
/// Why this exists: `check_at_boot` parses the on-disk LIC1 key and
+4 -5
View File
@@ -39,13 +39,12 @@ async fn main() -> anyhow::Result<()> {
// --- self-license tier (Keysat-licenses-Keysat) ---
// Verifies any /data/keysat-license.txt against the embedded master
// pubkey. In permissive builds (default) a missing/invalid license
// logs a warning and we continue. In enforce builds (compiled with
// KEYSAT_LICENSE_ENFORCE=1) a missing/invalid license refuses to
// start. Result is held in app state so the admin UI can surface it.
// pubkey. Missing/invalid licenses fall back to the Creator (free)
// tier — the daemon always boots. Result is held in app state so
// the admin UI can surface it.
let self_tier = Arc::new(tokio::sync::RwLock::new(
license_self::check_at_boot()
.context("Keysat self-license check failed (enforce mode)")?,
.context("Keysat self-license boot check")?,
));
// --- database ---
+6
View File
@@ -228,6 +228,12 @@ pub struct Policy {
/// policy. See TIER_UPGRADES_DESIGN.md for the full semantics.
#[serde(default)]
pub tier_rank: Option<i64>,
/// Soft-archive timestamp (migration 0015). `None` = live. `Some(ts)` =
/// archived: hidden from admin grid by default, hidden from /buy/<slug>,
/// renewal worker refuses to renew. Existing licenses keep validating
/// regardless (entitlements are signed into the key).
#[serde(default)]
pub archived_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
+49
View File
@@ -577,6 +577,55 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
);
}
// 0a. Refuse to renew an archived policy. The operator has
// explicitly taken this tier out of circulation. We dispatch a
// clear webhook + audit event so the operator can decide
// whether to unarchive or accept the lapse. The sub is left in
// its current state — the lapsing worker will eventually move
// it to `lapsed` when its grace period expires.
let policy_for_check =
crate::db::repo::get_policy_by_id(&state.db, &sub.policy_id).await?;
if let Some(policy) = policy_for_check.as_ref() {
if policy.archived_at.is_some() {
tracing::warn!(
sub_id = %sub.id,
policy_id = %sub.policy_id,
policy_slug = %policy.slug,
"skipping renewal: policy is archived",
);
let _ = crate::db::repo::insert_audit(
&state.db,
"renewal_worker",
None,
"subscription.renewal_skipped_archived",
Some("subscription"),
Some(&sub.id),
None,
None,
&json!({
"policy_id": sub.policy_id,
"policy_slug": policy.slug,
"reason": "policy_archived",
}),
)
.await;
crate::webhooks::dispatch(
state,
"subscription.renewal_skipped",
&json!({
"subscription_id": sub.id,
"license_id": sub.license_id,
"product_id": sub.product_id,
"policy_id": sub.policy_id,
"policy_slug": policy.slug,
"reason": "policy_archived",
}),
)
.await;
return Ok(());
}
}
// 1. Convert listed price to sats. SAT-currency subs are an
// identity (no rate fetcher hit); fiat subs re-quote each
// cycle (per MULTI_CURRENCY_DESIGN.md decision).
+167
View File
@@ -1224,6 +1224,26 @@ async fn payment_provider_preference_round_trip() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Zaprite activation requires the `zaprite_payments` entitlement
// (Pro tier and above). Pin the daemon's self-tier to a Licensed
// tier carrying that entitlement so the activate path doesn't
// 402. BTCPay is unconditional and works at every tier.
{
let mut guard = state.self_tier.write().await;
*guard = keysat::license_self::Tier::Licensed {
license_id: uuid::Uuid::new_v4(),
product_id: uuid::Uuid::new_v4(),
expires_at: 0,
entitlements: vec![
"unlimited_products".to_string(),
"unlimited_policies".to_string(),
"unlimited_codes".to_string(),
"recurring_billing".to_string(),
"zaprite_payments".to_string(),
],
};
}
// Pre-seed both configs as if the operator had run Connect on
// each at some point. We bypass the actual Connect endpoints
// because they call out to BTCPay / Zaprite to validate the
@@ -2877,3 +2897,150 @@ async fn buyer_cancel_rejects_garbage_key() {
"garbage key must be 401, not 404 — don't leak which subs exist"
);
}
// ---------------------------------------------------------------------
// 0.2.0:12 — Scoped API keys + OpenAPI spec + Zaprite gate
// ---------------------------------------------------------------------
/// `GET /v1/openapi.json` — public, no auth. Returns a parseable spec
/// with the agent-relevant subset of endpoints documented.
#[tokio::test]
async fn openapi_spec_serves_valid_json() {
let (state, _tmp) = make_test_state().await;
let req = build_request("GET", "/v1/openapi.json", &[], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert_eq!(v["openapi"], "3.1.0");
assert!(v["paths"].as_object().expect("paths is object").len() > 5);
// Spot-check that the agent-relevant endpoints are present.
assert!(v.pointer("/paths/~1v1~1admin~1api-keys").is_some());
assert!(v.pointer("/paths/~1v1~1admin~1licenses").is_some());
assert!(v.pointer("/paths/~1v1~1validate").is_some());
}
/// `POST /v1/admin/api-keys` — master admin creates a scoped key, the
/// raw token comes back once, and the role is recorded. Subsequent
/// `GET /v1/admin/api-keys` lists it without the token.
#[tokio::test]
async fn scoped_api_key_create_list_revoke_round_trip() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Create with a recognized role.
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &auth)],
Some(json!({"label": "Smoke test bot", "role": "license-issuer"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let token = body["token"].as_str().expect("token returned");
assert!(token.starts_with("ks_"), "scoped token must use ks_ prefix");
let key_id = body["id"].as_str().expect("id returned").to_string();
assert_eq!(body["role"], "license-issuer");
// List sees the new key but never the raw token.
let req = build_request("GET", "/v1/admin/api-keys", &[("authorization", &auth)], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let list = body_json(resp).await;
let keys = list["api_keys"].as_array().expect("api_keys array");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0]["label"], "Smoke test bot");
assert!(keys[0].get("token").is_none(), "list must not return raw tokens");
// Revoke. Idempotent on second call.
let path = format!("/v1/admin/api-keys/{}", key_id);
let req = build_request("DELETE", &path, &[("authorization", &auth)], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let req = build_request("DELETE", &path, &[("authorization", &auth)], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["already_revoked"], true);
}
/// Create endpoint rejects unknown role with 400.
#[tokio::test]
async fn scoped_api_key_create_rejects_unknown_role() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &auth)],
Some(json!({"label": "bad role", "role": "god-mode"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
/// `POST /v1/admin/api-keys` requires master admin, NOT a scoped
/// full-admin key — generating other API keys is a self-elevation path
/// that scoped keys are deliberately denied.
#[tokio::test]
async fn scoped_api_key_management_rejects_scoped_full_admin() {
let (state, _tmp) = make_test_state().await;
let master = format!("Bearer {}", TEST_ADMIN_KEY);
// Master creates a full-admin scoped key.
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &master)],
Some(json!({"label": "Tries to elevate", "role": "full-admin"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let scoped_token = body["token"].as_str().expect("token").to_string();
let scoped_auth = format!("Bearer {}", scoped_token);
// Scoped full-admin tries to create another key. Should 403 — the
// /v1/admin/api-keys handler calls require_admin, not require_scope.
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &scoped_auth)],
Some(json!({"label": "Pwn", "role": "read-only"})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"scoped keys (even full-admin) must NOT manage other keys"
);
}
/// Zaprite Connect refuses on Creator-tier (no `zaprite_payments`
/// entitlement) with 402. Switching the daemon's self-tier to a
/// Pro-flavored Licensed tier lets the Connect-precheck pass (it then
/// fails downstream on the unreachable test host, but the tier gate is
/// behind us).
#[tokio::test]
async fn zaprite_connect_gated_by_pro_entitlement() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Creator tier (default for test fixture) — Connect should 402.
let req = build_request(
"POST",
"/v1/admin/zaprite/connect",
&[("authorization", &auth)],
Some(json!({"api_key": "fake-zaprite-key"})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::PAYMENT_REQUIRED,
"Zaprite Connect must 402 without zaprite_payments entitlement"
);
let body = body_json(resp).await;
assert_eq!(body["error"], "tier_cap");
assert!(body["upgrade_url"].as_str().expect("upgrade_url").contains("/buy/keysat"));
}
File diff suppressed because it is too large Load Diff