v0.1.0:25–40 — tier model, edit forms, force-delete, license counts, migration 0009 (and hotfix); KEYSAT_INTEGRATION.md merged with downstream-LLM revisions

This commit is contained in:
Grant
2026-05-07 23:35:22 -05:00
parent 6ac118ae70
commit beedd07f07
27 changed files with 5576 additions and 134 deletions
+5 -1
View File
@@ -69,8 +69,12 @@ RUN case "${TARGETARCH}" in \
# -------- runtime --------
FROM debian:bookworm-slim AS runtime
# `sqlite3` is bundled in the runtime image so operators dropped into the
# container via `start-cli package attach keysat` have an SQL shell on hand
# for occasional admin tasks (test-data reset, hot-fix queries, audit
# inspection). The CLI binary is ~1.4 MB stripped — negligible.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tini \
ca-certificates tini sqlite3 \
&& rm -rf /var/lib/apt/lists/*
# Run as root inside the container. StartOS containers are isolated by
File diff suppressed because it is too large Load Diff
+3
View File
@@ -51,6 +51,9 @@ rand = "0.8"
sha2 = "0.10"
hmac = "0.12"
subtle = "2"
# Web-UI password hashing. Argon2id is the modern PHC-recommended default;
# this is the reference implementation in pure Rust (no FFI).
argon2 = "0.5"
# Encoding
data-encoding = "2" # Crockford base32 for license keys
@@ -0,0 +1,26 @@
-- Tiered pricing UX (v0.1.0:27).
--
-- Two changes, both additive:
--
-- 1. Mark policies as buyer-visible. Operators may have policies they don't
-- want to render on the public /buy/<slug> page (e.g. "Comp / press
-- giveaway", "Internal team seat"). Defaults to public=1 so existing
-- policies keep their current behaviour.
--
-- 2. Remember which policy the buyer chose at purchase time. Today,
-- `issue_license_for_invoice` picks the "default" policy (or first
-- active) for the product. With multi-tier pricing, the buyer's
-- explicit choice needs to round-trip from /buy → BTCPay invoice →
-- settlement webhook → license issuance. Storing it on the invoice is
-- the simplest place — it sticks even if the policy is later
-- deactivated, and the FK keeps integrity. NULL means "fall back to
-- the product's default policy" for backwards compatibility with
-- pre-:27 invoices.
PRAGMA foreign_keys = ON;
ALTER TABLE policies ADD COLUMN public INTEGER NOT NULL DEFAULT 1;
ALTER TABLE invoices ADD COLUMN policy_id TEXT REFERENCES policies(id);
-- Helps the public buy-page endpoint enumerate visible tiers cheaply.
CREATE INDEX IF NOT EXISTS idx_policies_public ON policies(public);
@@ -0,0 +1,26 @@
-- Web UI password + session-based authentication.
--
-- Until v0.1.0:28 the only credential was the admin API key, which the
-- SPA stored in localStorage every login. This migration sets up the
-- alternate path: the operator sets a password (argon2id-hashed in the
-- settings table under key 'web_ui_password_hash'); successful login
-- issues a session token stored as an HttpOnly cookie. The API key
-- continues to work for automation; admin endpoints accept either
-- credential.
--
-- A future migration may add per-user accounts. For v0.1 there's a
-- single admin password — the StartOS service is single-tenant by
-- design and an operator's StartOS already gates physical access.
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY, -- random 32-byte URL-safe base64
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL, -- ISO-8601 UTC
last_seen_at TEXT NOT NULL,
ip TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
@@ -0,0 +1,54 @@
-- Allow `kind = 'set_price'` on discount_codes (added at the daemon
-- level in v0.1.0:26 but the migration that created the CHECK constraint
-- in 0004 didn't include it, so existing instances reject the new kind
-- with "CHECK constraint failed").
--
-- SQLite doesn't support ALTER TABLE ... DROP CONSTRAINT, so we rebuild
-- the table: copy → drop old → rename. sqlx-migrate already wraps each
-- .sql file in a transaction, so we DON'T do BEGIN/COMMIT here (nested
-- transactions are not supported in SQLite).
--
-- `PRAGMA defer_foreign_keys = 1` is the transaction-local equivalent
-- of `foreign_keys = OFF`: it postpones FK constraint checks until
-- COMMIT time. This lets us drop the old discount_codes table without
-- the immediate FK check from discount_redemptions.code_id failing.
-- The IDs are preserved across the rebuild, so when the FK check runs
-- at COMMIT, every referencing row still resolves cleanly.
PRAGMA defer_foreign_keys = 1;
CREATE TABLE discount_codes_new (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL, -- 'percent' | 'fixed_sats' | 'set_price' | 'free_license'
amount INTEGER NOT NULL,
max_uses INTEGER,
used_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
applies_to_product_id TEXT,
applies_to_policy_id TEXT,
referrer_label TEXT,
description TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (applies_to_product_id) REFERENCES products(id),
FOREIGN KEY (applies_to_policy_id) REFERENCES policies(id),
CHECK (kind IN ('percent', 'fixed_sats', 'set_price', 'free_license')),
CHECK (amount >= 0),
CHECK (used_count >= 0)
);
INSERT INTO discount_codes_new
SELECT id, code, kind, amount, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label,
description, active, created_at, updated_at
FROM discount_codes;
DROP TABLE discount_codes;
ALTER TABLE discount_codes_new RENAME TO discount_codes;
CREATE INDEX IF NOT EXISTS idx_discount_codes_active ON discount_codes(active);
CREATE INDEX IF NOT EXISTS idx_discount_codes_product ON discount_codes(applies_to_product_id);
CREATE INDEX IF NOT EXISTS idx_discount_codes_policy ON discount_codes(applies_to_policy_id);
CREATE INDEX IF NOT EXISTS idx_discount_codes_expires ON discount_codes(expires_at);
+420 -2
View File
@@ -21,6 +21,15 @@ use subtle::ConstantTimeEq;
/// SHA-256 hex of the token on success so handlers can write an audit row
/// that identifies *which* credential made the call without logging the raw
/// key.
///
/// Cookie-based session authentication is layered on top of this via the
/// `session_to_bearer_layer` axum middleware (see `crate::api::session_layer`):
/// when the SPA presents a valid `keysat_session` cookie, that middleware
/// injects an `Authorization: Bearer <api_key>` header on the way in, so
/// `require_admin` keeps working unchanged. The audit log limitation is
/// that all cookie-authenticated calls show the API key's sha256 as the
/// actor — IP / user-agent on the same row distinguish sessions in
/// practice. A v0.2 follow-up adds proper per-session actor identity.
pub fn require_admin(state: &AppState, headers: &HeaderMap) -> AppResult<String> {
let header_val = headers
.get(header::AUTHORIZATION)
@@ -77,6 +86,8 @@ pub async fn create_product(
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// Tier-cap gate: Creator caps at 5 products. 402 if over.
crate::api::tier::enforce_product_cap(&state).await?;
if req.price_sats <= 0 {
return Err(AppError::BadRequest("price_sats must be positive".into()));
}
@@ -120,6 +131,227 @@ pub struct SetActiveReq {
pub active: bool,
}
/// Query options for product / policy delete.
#[derive(Debug, Deserialize)]
pub struct DeleteOpts {
/// When true, cascades through every dependent row — licenses,
/// invoices, discount-code redemptions, machines — instead of
/// refusing with 409. Use only when tinkering or wiping pre-launch
/// test data; in production this destroys customer history.
#[serde(default)]
pub force: bool,
}
/// Hard-delete a product. Two modes:
///
/// - **Safe (default)**: refuses if any invoice or license references
/// the product. Policies and unredeemed product-scoped codes are
/// cascade-deleted along with the product (templates only — no
/// audit-trail value on their own).
///
/// - **Force (`?force=true`)**: also wipes machines → discount
/// redemptions → licenses → invoices in dependency order before
/// removing the product. Destructive; reserved for testing /
/// pre-launch cleanup. Audit log records the cascade counts for
/// forensic backtracking.
pub async fn delete_product(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Query(opts): Query<DeleteOpts>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let product = repo::get_product_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{id}'")))?;
let invoice_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE product_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
let license_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE product_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
if !opts.force && invoice_count + license_count > 0 {
return Err(AppError::Conflict(format!(
"cannot delete product '{}' — it has {} invoice(s) and {} license(s) \
referencing it. Disable it instead (existing licenses keep working; \
the product just stops being available for new purchases). To override \
and wipe all references, use ?force=true.",
product.slug, invoice_count, license_count
)));
}
// Count what we'll cascade — informational, for the audit row + response.
let policy_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM policies WHERE product_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
let code_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM discount_codes WHERE applies_to_product_id = ?",
)
.bind(&id)
.fetch_one(&state.db)
.await?;
let machine_count: i64 = if opts.force {
sqlx::query_scalar(
"SELECT COUNT(*) FROM machines WHERE license_id IN
(SELECT id FROM licenses WHERE product_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 product_id = ?)",
)
.bind(&id)
.fetch_one(&state.db)
.await?
} else {
0
};
// Cascade. Wrapped in a transaction so a partial failure leaves
// consistent state.
let mut tx = state.db.begin().await?;
if opts.force {
// Force: also wipe customer-history rows. Order matters — most
// dependent rows first.
sqlx::query(
"DELETE FROM machines WHERE license_id IN
(SELECT id FROM licenses WHERE product_id = ?)",
)
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM discount_redemptions WHERE invoice_id IN
(SELECT id FROM invoices WHERE product_id = ?)",
)
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM licenses WHERE product_id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM invoices WHERE product_id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
}
sqlx::query("DELETE FROM discount_codes WHERE applies_to_product_id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM policies WHERE product_id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM products WHERE id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
if opts.force { "product.force_delete" } else { "product.delete" },
Some("product"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"slug": product.slug,
"name": product.name,
"force": opts.force,
"cascaded_policies": policy_count,
"cascaded_codes": code_count,
"cascaded_licenses": if opts.force { license_count } else { 0 },
"cascaded_invoices": if opts.force { invoice_count } else { 0 },
"cascaded_machines": machine_count,
"cascaded_redemptions": redemption_count,
}),
)
.await;
Ok(Json(json!({
"ok": true,
"deleted": product.slug,
"force": opts.force,
"cascaded_policies": policy_count,
"cascaded_codes": code_count,
"cascaded_licenses": if opts.force { license_count } else { 0 },
"cascaded_invoices": if opts.force { invoice_count } else { 0 },
"cascaded_machines": machine_count,
"cascaded_redemptions": redemption_count,
})))
}
/// Patch mutable fields on a product. Slug is NOT editable — it's part
/// of the public buy URL.
#[derive(Debug, Deserialize)]
pub struct UpdateProductReq {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub price_sats: Option<i64>,
}
pub async fn update_product(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<UpdateProductReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if let Some(p) = req.price_sats {
if p < 0 {
return Err(AppError::BadRequest("price_sats must be >= 0".into()));
}
}
let updated = repo::update_product(
&state.db,
&id,
req.name.as_deref(),
req.description.as_deref(),
req.price_sats,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"product.update",
Some("product"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"name": req.name,
"description": req.description,
"price_sats": req.price_sats,
}),
)
.await;
Ok(Json(json!(updated)))
}
pub async fn set_product_active(
State(state): State<AppState>,
headers: HeaderMap,
@@ -170,7 +402,11 @@ pub struct SearchLicensesQuery {
/// Free-form lookup used by the "lost key recovery" flow. Searches by email,
/// Nostr npub, or invoice id (whichever is supplied), returns up to 100
/// matching licenses.
/// matching licenses. With no filters supplied, returns the 100 most-recent
/// licenses (used by the admin UI's "recent licenses" default view).
///
/// Each row is hydrated with `policy_slug`, `policy_name`, and `product_slug`
/// so the admin UI can render those without extra round-trips.
pub async fn search_licenses(
State(state): State<AppState>,
headers: HeaderMap,
@@ -184,7 +420,189 @@ pub async fn search_licenses(
q.invoice_id.as_deref(),
)
.await?;
Ok(Json(json!({ "licenses": licenses })))
// Hydrate with policy + product slugs. Two small lookup queries against
// the unique ids referenced; cheap even for the 100-row max page.
let policy_ids: Vec<String> = licenses
.iter()
.filter_map(|l| l.policy_id.clone())
.collect();
let product_ids: Vec<String> = licenses
.iter()
.map(|l| l.product_id.clone())
.collect();
let mut policy_map: std::collections::HashMap<String, (String, String)> =
std::collections::HashMap::new();
if !policy_ids.is_empty() {
let placeholders = vec!["?"; policy_ids.len()].join(",");
let sql = format!("SELECT id, slug, name FROM policies WHERE id IN ({placeholders})");
let mut q = sqlx::query_as::<_, (String, String, String)>(&sql);
for id in &policy_ids {
q = q.bind(id);
}
for (id, slug, name) in q.fetch_all(&state.db).await? {
policy_map.insert(id, (slug, name));
}
}
let mut product_map: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
if !product_ids.is_empty() {
let placeholders = vec!["?"; product_ids.len()].join(",");
let sql = format!("SELECT id, slug FROM products WHERE id IN ({placeholders})");
let mut q = sqlx::query_as::<_, (String, String)>(&sql);
for id in &product_ids {
q = q.bind(id);
}
for (id, slug) in q.fetch_all(&state.db).await? {
product_map.insert(id, slug);
}
}
let enriched: Vec<Value> = licenses
.into_iter()
.map(|l| {
let mut v = serde_json::to_value(&l).unwrap_or(json!({}));
if let Some(pid) = &l.policy_id {
if let Some((slug, name)) = policy_map.get(pid) {
v["policy_slug"] = json!(slug);
v["policy_name"] = json!(name);
}
}
if let Some(slug) = product_map.get(&l.product_id) {
v["product_slug"] = json!(slug);
}
v
})
.collect();
Ok(Json(json!({ "licenses": enriched })))
}
/// Lifetime / 30d / 7d / 24h revenue from settled BTCPay invoices stored
/// locally. Powers the admin Overview "Revenue" stat card. Free-license
/// invoices have amount_sats = 0 and don't contribute. We deliberately
/// don't call the BTCPay API here — the local DB has every invoice we
/// ever created, including amount and status, so summing locally is
/// faster and works even if BTCPay is temporarily unreachable. (If we
/// ever want refunds / fees / chargebacks / Lightning vs on-chain
/// breakdown, that's when we'd hit BTCPay's API.)
pub async fn revenue_summary(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let total: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount_sats), 0) FROM invoices WHERE status = 'settled'",
)
.fetch_one(&state.db)
.await?;
let last_24h: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount_sats), 0) FROM invoices
WHERE status = 'settled' AND updated_at >= datetime('now','-24 hours')",
)
.fetch_one(&state.db)
.await?;
let last_7d: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount_sats), 0) FROM invoices
WHERE status = 'settled' AND updated_at >= datetime('now','-7 days')",
)
.fetch_one(&state.db)
.await?;
let last_30d: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount_sats), 0) FROM invoices
WHERE status = 'settled' AND updated_at >= datetime('now','-30 days')",
)
.fetch_one(&state.db)
.await?;
let settled_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE status = 'settled' AND amount_sats > 0")
.fetch_one(&state.db)
.await?;
Ok(Json(json!({
"total_sats": total,
"last_24h_sats": last_24h,
"last_7d_sats": last_7d,
"last_30d_sats": last_30d,
"settled_paid_invoice_count": settled_count,
})))
}
/// License counts grouped by product_id and policy_id. Powers the
/// "X licenses" badge on the Products and Policies tables. Two small
/// COUNT-by-group queries; cheap to run on every Products/Policies route
/// open.
pub async fn license_counts(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let by_product: Vec<(String, i64)> = sqlx::query_as(
"SELECT product_id, COUNT(*) FROM licenses GROUP BY product_id",
)
.fetch_all(&state.db)
.await?;
let by_policy: Vec<(Option<String>, i64)> = sqlx::query_as(
"SELECT policy_id, COUNT(*) FROM licenses GROUP BY policy_id",
)
.fetch_all(&state.db)
.await?;
let by_product_map: serde_json::Map<String, Value> = by_product
.into_iter()
.map(|(id, n)| (id, Value::from(n)))
.collect();
let by_policy_map: serde_json::Map<String, Value> = by_policy
.into_iter()
.filter_map(|(id, n)| id.map(|i| (i, Value::from(n))))
.collect();
Ok(Json(json!({
"by_product": by_product_map,
"by_policy": by_policy_map,
})))
}
/// Aggregate counts for the admin Overview dashboard. Populates the
/// "Active licenses" stat card (and is small/cheap enough to query on
/// every dashboard load).
pub async fn licenses_summary(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses")
.fetch_one(&state.db)
.await?;
let active: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE status = 'active'")
.fetch_one(&state.db)
.await?;
let suspended: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE status = 'suspended'")
.fetch_one(&state.db)
.await?;
let revoked: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE status = 'revoked'")
.fetch_one(&state.db)
.await?;
let last_24h: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM licenses WHERE issued_at >= datetime('now','-24 hours')",
)
.fetch_one(&state.db)
.await?;
let last_7d: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM licenses WHERE issued_at >= datetime('now','-7 days')",
)
.fetch_one(&state.db)
.await?;
Ok(Json(json!({
"total": total,
"active": active,
"suspended": suspended,
"revoked": revoked,
"last_24h": last_24h,
"last_7d": last_7d,
})))
}
#[derive(Debug, Deserialize)]
+276
View File
@@ -0,0 +1,276 @@
//! Web-UI password authentication. Sits alongside the existing admin
//! API key path; admin endpoints accept either credential.
//!
//! Flow:
//! 1. Operator sets a password via the StartOS action "Set web UI
//! password" (which POSTs to /v1/admin/web-password using the API
//! key). Daemon argon2id-hashes the password and stores it under
//! the settings key `web_ui_password_hash`.
//! 2. SPA login form POSTs `{password}` to /admin/login. Daemon
//! verifies, mints a 32-byte random session token, persists in
//! sessions table, sets it as `keysat_session` HttpOnly +
//! SameSite=Strict cookie. Token TTL: 24h, sliding via last_seen_at
//! bump on every authenticated request.
//! 3. Subsequent admin calls present the cookie OR the API key.
//! `require_admin_or_session` accepts either.
//! 4. /admin/logout deletes the session row + clears the cookie.
//! 5. A background task in main.rs reaps expired sessions hourly.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use rand::rngs::OsRng;
use axum::{
body::Body,
extract::State,
http::{header, HeaderMap, StatusCode},
response::Response,
Json,
};
use base64::Engine;
use rand::RngCore;
use serde::Deserialize;
use serde_json::{json, Value};
/// Settings key for the argon2id password hash (PHC-format string).
pub const SETTING_WEB_UI_PASSWORD_HASH: &str = "web_ui_password_hash";
/// Cookie name for the session token.
pub const SESSION_COOKIE: &str = "keysat_session";
/// Default session TTL — 24 hours from creation. Renewed on every
/// authenticated request via last_seen_at bump (sliding window).
pub const SESSION_TTL_SECS: i64 = 60 * 60 * 24;
/// Hash a plaintext password using Argon2id with the PHC-recommended
/// parameters. Returns a PHC-format string suitable for storage.
pub fn hash_password(plaintext: &str) -> AppResult<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(plaintext.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 hash failed: {e}")))
}
/// Verify a plaintext password against a stored PHC-format hash.
pub fn verify_password(plaintext: &str, phc_hash: &str) -> bool {
PasswordHash::new(phc_hash)
.and_then(|parsed| Argon2::default().verify_password(plaintext.as_bytes(), &parsed))
.is_ok()
}
/// Generate a cryptographically random 32-byte session token, URL-safe
/// base64-encoded (no padding). 256 bits of entropy.
fn new_session_token() -> String {
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
}
#[derive(Debug, Deserialize)]
pub struct SetPasswordReq {
/// Plaintext password. Minimum 12 chars enforced server-side.
pub password: String,
}
/// Admin-only (via API key): sets or rotates the web UI password.
/// Invalidates all existing sessions when the password changes so that
/// stale browsers re-authenticate. Audit-logged.
pub async fn set_password(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<SetPasswordReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if req.password.len() < 12 {
return Err(AppError::BadRequest(
"password must be at least 12 characters".into(),
));
}
let hash = hash_password(&req.password)?;
repo::settings_set(&state.db, SETTING_WEB_UI_PASSWORD_HASH, Some(&hash)).await?;
// Invalidate all existing sessions on rotation so the new password
// takes effect everywhere immediately.
repo::delete_all_sessions(&state.db).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"web_ui.set_password",
Some("settings"),
Some(SETTING_WEB_UI_PASSWORD_HASH),
ip.as_deref(),
ua.as_deref(),
&json!({ "ok": true }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
#[derive(Debug, Deserialize)]
pub struct LoginReq {
pub password: String,
}
/// Public login endpoint. Verifies the password against the stored hash;
/// on success, issues a session and returns it as an HttpOnly cookie.
/// Per-IP rate limiting on bad attempts is enforced via the existing
/// rate_limit module. Returns 204 No Content on success (no body — the
/// cookie is the credential), 401 on bad password, 503 when no password
/// is configured yet.
pub async fn login(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<LoginReq>,
) -> Result<Response, AppError> {
let (ip, ua) = request_context(&headers);
// Brute-force protection: token-bucket per client IP. Capacity 5,
// refills at 1 token / 180s (so a sustained brute-forcer is throttled
// to ~20 attempts/hour after the initial burst). Backed by SQLite via
// the existing rate_limit::consume helper.
let bucket_key = ip.as_deref().unwrap_or("unknown");
let allowed = crate::rate_limit::consume(
&state.db,
"web_login",
bucket_key,
5.0,
1.0 / 180.0,
)
.await?;
if !allowed {
return Err(AppError::TooManyRequests(
"too many login attempts; try again in a few minutes".into(),
));
}
let stored = repo::settings_get(&state.db, SETTING_WEB_UI_PASSWORD_HASH).await?;
let Some(hash) = stored else {
return Err(AppError::ServiceUnavailable(
"web UI password is not configured. Set one via the StartOS \"Set web UI password\" action."
.into(),
));
};
if !verify_password(&req.password, &hash) {
// Audit failed attempt — useful for spotting brute-force.
let _ = repo::insert_audit(
&state.db,
"web_ui",
None,
"web_ui.login_failed",
Some("settings"),
Some(SETTING_WEB_UI_PASSWORD_HASH),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
return Err(AppError::Unauthorized);
}
let token = new_session_token();
let now = chrono::Utc::now();
let expires = now + chrono::Duration::seconds(SESSION_TTL_SECS);
repo::create_session(
&state.db,
&token,
&now.to_rfc3339(),
&expires.to_rfc3339(),
ip.as_deref(),
ua.as_deref(),
)
.await?;
let _ = repo::insert_audit(
&state.db,
"web_ui",
None,
"web_ui.login_ok",
Some("session"),
Some(&token),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
// HttpOnly + Secure + SameSite=Strict + path=/ keeps the cookie out
// of JS and out of cross-site contexts. Max-Age in seconds.
let cookie = format!(
"{name}={token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age={ttl}",
name = SESSION_COOKIE,
token = token,
ttl = SESSION_TTL_SECS,
);
Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::SET_COOKIE, cookie)
.body(Body::empty())
.map_err(|e| AppError::Internal(anyhow::anyhow!("response build failed: {e}")))
}
/// Logs the caller out. Reads the session cookie, deletes the matching
/// session row, and emits a Set-Cookie that clears the cookie on the
/// browser side. Idempotent: returns 204 even if the cookie is missing
/// or already invalid.
pub async fn logout(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, AppError> {
if let Some(token) = extract_session_cookie(&headers) {
let _ = repo::delete_session(&state.db, &token).await;
}
let cleared = format!(
"{name}=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0",
name = SESSION_COOKIE,
);
Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::SET_COOKIE, cleared)
.body(Body::empty())
.map_err(|e| AppError::Internal(anyhow::anyhow!("response build failed: {e}")))
}
/// Lightweight status probe used by the SPA on first load. Tells the
/// client whether a password has been configured (so it can show "Set a
/// password via StartOS Actions" if not) and whether the current session
/// cookie is valid (so it can skip the login form).
pub async fn login_status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let has_password = repo::settings_get(&state.db, SETTING_WEB_UI_PASSWORD_HASH)
.await?
.is_some();
let logged_in = if let Some(token) = extract_session_cookie(&headers) {
repo::is_session_valid(&state.db, &token).await?
} else {
false
};
Ok(Json(json!({
"has_password": has_password,
"logged_in": logged_in,
})))
}
/// Read the session token out of the Cookie header, if present. Naive
/// parser — handles the typical `Cookie: a=1; keysat_session=…; b=2`
/// shape and is robust to quoted values and stray whitespace.
pub fn extract_session_cookie(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for pair in raw.split(';') {
let pair = pair.trim();
if let Some((k, v)) = pair.split_once('=') {
if k.trim() == SESSION_COOKIE {
return Some(v.trim().trim_matches('"').to_string());
}
}
}
None
}
+373 -18
View File
@@ -18,14 +18,24 @@
use crate::api::AppState;
use crate::db::repo;
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::Html,
};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct BuyPageQuery {
/// Optional tier slug (deep-link support). Pre-selects a tier when
/// the buyer arrives from a tier-specific marketing CTA.
#[serde(default)]
pub policy: Option<String>,
}
pub async fn render(
State(state): State<AppState>,
Path(slug): Path<String>,
Query(q): Query<BuyPageQuery>,
) -> Result<Html<String>, (StatusCode, Html<String>)> {
// Look up the product. Inactive or missing → 404 with a friendly page.
let product = match repo::get_product_by_slug(&state.db, &slug).await {
@@ -47,7 +57,54 @@ pub async fn render(
let product_name = html_escape(&product.name);
let product_slug = html_escape(&product.slug);
let product_description = html_escape(&product.description);
let price_sats_fmt = format_thousands(product.price_sats);
// Tiered pricing: fetch active+public policies for this product. Sorted
// by price ascending. Used to (a) decide whether to render the tier
// picker (≥ 2 tiers), and (b) compute the displayed price for the
// initially-selected tier.
let public_policies = repo::list_public_policies_by_product(&state.db, &product.id)
.await
.unwrap_or_default();
// Determine the initial selection: ?policy=<slug> deep-link wins, then
// any policy marked metadata.highlight=true, then the first (cheapest)
// policy, then None (single-price view).
let initial_policy = if let Some(want) = q.policy.as_deref() {
public_policies.iter().find(|p| p.slug == want).cloned()
} else {
None
}
.or_else(|| {
public_policies
.iter()
.find(|p| {
p.metadata
.get("highlight")
.and_then(|v| v.as_bool())
.unwrap_or(false)
})
.cloned()
})
.or_else(|| public_policies.first().cloned());
// The price displayed in the cert card on initial render.
let displayed_price = initial_policy
.as_ref()
.and_then(|p| p.price_sats_override)
.unwrap_or(product.price_sats);
let price_sats_fmt = format_thousands(displayed_price);
let initial_policy_slug = initial_policy
.as_ref()
.map(|p| p.slug.clone())
.unwrap_or_default();
// Server-render the tier picker HTML so the page is functional even
// before JS runs. The picker only appears when the product has 2+
// public policies; otherwise the existing single-price view is used.
let tiers_html = render_tier_picker(&public_policies, &initial_policy, &product);
// Compact JSON map of {policy_slug: {price, name}} so the JS can update
// the price card when the buyer clicks a different tier.
let tiers_json = build_tiers_json(&public_policies, &product);
let body = format!(
r#"<!doctype html>
@@ -161,6 +218,90 @@ h1 {{
}}
.field .hint {{ font-size:12px; color:var(--ink-500); margin-top:5px; }}
/* Tier picker — shown when product has 2+ public policies. */
.tiers {{
display:grid; gap:14px; margin:0 0 28px;
}}
.tiers-2 {{ grid-template-columns:repeat(2, 1fr); }}
.tiers-3 {{ grid-template-columns:repeat(3, 1fr); }}
.tiers-4 {{ grid-template-columns:repeat(2, 1fr); }}
@media (max-width:560px) {{
.tiers-2, .tiers-3, .tiers-4 {{ grid-template-columns:1fr; }}
}}
.tier {{
position:relative;
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:12px; padding:22px 20px 20px;
display:flex; flex-direction:column; gap:10px;
cursor:pointer; transition:all 150ms ease;
}}
.tier:hover {{
border-color:var(--gold-500);
box-shadow:0 4px 12px rgba(14,31,51,0.08);
transform:translateY(-1px);
}}
.tier.selected {{
border-color:var(--gold-500); border-width:2px;
padding:21px 19px 19px; /* compensate for thicker border */
background:#fff;
box-shadow:0 0 0 3px rgba(191,160,104,0.12), 0 8px 16px rgba(14,31,51,0.10);
}}
.tier.highlighted {{ border-color:var(--gold-500); }}
.tier-popular {{
position:absolute; top:-10px; left:50%; transform:translateX(-50%);
background:var(--gold-500); color:var(--navy-950);
font-family:var(--font-body); font-size:10.5px; font-weight:700;
letter-spacing:0.16em; text-transform:uppercase;
padding:4px 10px; border-radius:999px;
white-space:nowrap;
}}
.tier-name {{
font-family:var(--font-display); font-weight:600; font-size:18px;
color:var(--navy-950); letter-spacing:-0.01em;
}}
.tier-price {{
font-family:var(--font-display); font-weight:700; font-size:26px;
color:var(--navy-950); letter-spacing:-0.02em;
line-height:1.1;
}}
.tier-price-unit {{
font-family:var(--font-body); font-size:13px; font-weight:500;
color:var(--ink-500); margin-left:6px;
}}
.tier-meta {{
font-size:12px; color:var(--ink-500);
font-family:var(--font-body); font-weight:500;
}}
.tier-description {{
font-size:13.5px; line-height:1.45; color:var(--ink-700); margin:0;
}}
.tier-entitlements {{
list-style:none; padding:0; margin:6px 0 0;
font-size:13px; color:var(--ink-700);
}}
.tier-entitlements li {{
padding:3px 0 3px 18px; position:relative;
}}
.tier-entitlements li::before {{
content:'✓'; position:absolute; left:0; top:3px;
color:var(--gold-700); font-weight:700;
}}
.tier-select-btn {{
margin-top:auto;
padding:8px 12px;
background:transparent; color:var(--navy-800);
border:1px solid var(--border-2); border-radius:8px;
font-family:var(--font-body); font-weight:600; font-size:13px;
cursor:pointer; transition:all 120ms;
}}
.tier.selected .tier-select-btn {{
background:var(--navy-800); color:var(--cream-50);
border-color:var(--navy-800);
}}
.tier:hover .tier-select-btn {{
border-color:var(--navy-800);
}}
/* Apply-discount cluster: input + button on one row */
.code-row {{ display:flex; gap:8px; align-items:stretch; }}
.code-row input {{ flex:1; }}
@@ -293,8 +434,10 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
<div class="product-slug">{product_slug}</div>
<p class="description">{product_description}</p>
{tiers_html}
<div class="cert">
<div class="price-label">Price</div>
<div class="price-label" id="price-label">Price</div>
<div class="price" id="price-display">
<span id="price-strike-line" class="price-strike" style="display:none"></span>
<span id="price-current">{price_sats_fmt}</span><span class="unit">sats</span>
@@ -304,9 +447,9 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
<form id="buy-form">
<div class="field">
<label for="email">Email (for receipt &amp; license)</label>
<input type="email" id="email" name="email" placeholder="you@example.com" required>
<div class="hint">We&rsquo;ll send your license key here after payment confirms.</div>
<label for="email">Email <span style="color:var(--ink-500); font-weight:400">(optional)</span></label>
<input type="email" id="email" name="email" placeholder="you@example.com">
<div class="hint">Useful only if you want a buyer reference for lost-key recovery. Skip it to pay anonymously — your license key is shown directly on this site either way.</div>
</div>
<div class="field">
<label for="code">Discount code (optional)</label>
@@ -336,7 +479,10 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
<button id="license-key-copy">Copy</button>
</div>
<div class="save-note">
<strong>Save this somewhere safe.</strong> The license key is signed at issue time and verifies offline. We&rsquo;ll also send a copy to <span id="license-email-display"></span> for your records.
<strong>Save this somewhere safe.</strong> The license key is signed at issue time and verifies offline.
<div id="invoice-ref-line" style="margin-top:10px; font-family:var(--font-mono); font-size:12px; color:var(--ink-500); display:none">
Reference for support: <code id="invoice-ref-id" style="background:var(--cream-200); padding:1px 6px; border-radius:5px; color:var(--ink-700);"></code>
</div>
</div>
</div>
</div>
@@ -362,21 +508,89 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
const priceStrike = document.getElementById('price-strike-line');
const priceTag = document.getElementById('price-discount-tag');
const PRODUCT_SLUG = {slug_json};
// TIERS: {{ slug: {{name, price_sats}} }} — server-rendered. Empty if the
// product has no public policies (single-price view).
const TIERS = {tiers_json};
// Initial tier slug (server-determined: ?policy=, then highlighted, then cheapest).
let selectedPolicy = {initial_policy_json} || null;
const priceLabel = document.getElementById('price-label');
const BASE_PRICE_FMT = priceCurrent.textContent;
// State of the most recent successful Apply. When set with kind=free_license
// and the same code is still in the input, the submit handler skips the
// "try /v1/redeem then fall through" dance and goes straight to redeem.
// Recompute on tier change so the strike-through baseline tracks the
// currently-selected tier rather than freezing to the initial render.
let currentBaseFmt = BASE_PRICE_FMT;
// Hoisted up here (was previously declared further down) because the
// on-load `selectTier(selectedPolicy)` call below reads it. Leaving the
// declaration below the call hits the temporal-dead-zone error and kills
// every event handler on the page (including the form submit).
let appliedCode = null; // {{ code, kind, is_free, final_price_sats }}
function fmtSats(n) {{ return Number(n).toLocaleString('en-US'); }}
// Wire up tier-card clicks.
document.querySelectorAll('.tier').forEach(function(card) {{
card.addEventListener('click', function(e) {{
e.preventDefault();
const slug = card.getAttribute('data-policy-slug');
if (slug) selectTier(slug);
}});
}});
// On load, sync the price card + CTA to whatever tier was server-pre-selected.
// Without this, a free tier would render with "0" price and "Pay with Bitcoin"
// before the buyer interacts, which is wrong.
if (selectedPolicy && TIERS[selectedPolicy]) {{
selectTier(selectedPolicy);
}}
function selectTier(slug) {{
if (!TIERS[slug]) return;
selectedPolicy = slug;
// Visual update.
document.querySelectorAll('.tier').forEach(function(c) {{
if (c.getAttribute('data-policy-slug') === slug) c.classList.add('selected');
else c.classList.remove('selected');
}});
// Reset any active discount apply state — a different tier may not
// honor the same code (server validates again on the next Apply).
if (appliedCode) {{
appliedCode = null;
setStatus(null);
setPaidButton();
}}
// Reflect new base price in the cert card.
const t = TIERS[slug];
currentBaseFmt = fmtSats(t.price_sats);
priceStrike.style.display = 'none';
priceTag.style.display = 'none';
if (priceLabel) priceLabel.textContent = 'Price · ' + t.name;
// Free tier: render "FREE", swap CTA to "Redeem license" so the
// buyer never sees "Pay with Bitcoin" for a 0-sat product.
if (t.price_sats === 0) {{
priceCurrent.textContent = 'FREE';
setRedeemButton();
}} else {{
priceCurrent.textContent = currentBaseFmt;
setPaidButton();
}}
}}
// (appliedCode hoisted above — see comment near `let currentBaseFmt`.)
function showError(msg) {{
errEl.textContent = msg;
errEl.classList.add('show');
}}
function clearError() {{ errEl.classList.remove('show'); }}
function showLicense(licenseKey, email) {{
function showLicense(licenseKey, invoiceId) {{
keyTextEl.textContent = licenseKey;
emailDisplayEl.textContent = email || '(no email provided)';
if (invoiceId) {{
const refLine = document.getElementById('invoice-ref-line');
const refId = document.getElementById('invoice-ref-id');
if (refLine && refId) {{
refId.textContent = invoiceId;
refLine.style.display = 'block';
}}
}}
form.style.display = 'none';
successEl.classList.add('show');
successEl.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
@@ -394,7 +608,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
}}
function resetPrice() {{
priceCurrent.textContent = BASE_PRICE_FMT;
priceCurrent.textContent = currentBaseFmt;
priceStrike.style.display = 'none';
priceStrike.textContent = '';
priceTag.style.display = 'none';
@@ -430,8 +644,9 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
const orig = applyBtn.textContent;
applyBtn.textContent = 'Checking…';
try {{
const url = '/v1/discount-codes/preview?code='
let url = '/v1/discount-codes/preview?code='
+ encodeURIComponent(code) + '&product=' + encodeURIComponent(PRODUCT_SLUG);
if (selectedPolicy) url += '&policy_slug=' + encodeURIComponent(selectedPolicy);
const resp = await fetch(url);
if (!resp.ok) {{
let msg = 'HTTP ' + resp.status;
@@ -495,11 +710,12 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
product: PRODUCT_SLUG,
code,
buyer_email: email || undefined,
policy_slug: selectedPolicy || undefined,
}}),
}});
if (resp.ok) {{
const j = await resp.json();
return {{ ok: true, license_key: j.license_key }};
return {{ ok: true, license_key: j.license_key, invoice_id: j.invoice_id }};
}}
let msg = 'HTTP ' + resp.status;
try {{
@@ -516,6 +732,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
async function startPaidPurchase(code, email) {{
const body = {{ product: PRODUCT_SLUG, buyer_email: email || undefined }};
if (code) body.code = code;
if (selectedPolicy) body.policy_slug = selectedPolicy;
const resp = await fetch('/v1/purchase', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
@@ -530,8 +747,16 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
throw new Error(msg);
}}
const j = await resp.json();
// Free-tier shortcut: server issued the license inline (no BTCPay).
// Show the license card directly instead of redirecting to a 0-sat
// checkout page.
if (j.license_key) {{
showLicense(j.license_key, j.invoice_id);
return {{ inline: true }};
}}
if (!j.checkout_url) throw new Error('No checkout URL returned by server');
window.location.href = j.checkout_url;
return {{ inline: false }};
}}
// "Copy" on the license key box.
@@ -559,7 +784,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
// Fast path: a free_license code was already validated via Apply.
if (codeMatchesApplied && appliedCode.is_free) {{
const r = await tryFreeRedeem(code, email);
if (r.ok) {{ showLicense(r.license_key, email); return; }}
if (r.ok) {{ showLicense(r.license_key, r.invoice_id); return; }}
// If the server changed its mind, surface the error rather than silently
// routing to a paid flow that the buyer didn't consent to.
throw new Error(r.msg || 'Could not redeem free license.');
@@ -568,7 +793,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
// Slower path (no Apply or non-free code): keep the original try-then-fallthrough.
if (code) {{
const r = await tryFreeRedeem(code, email);
if (r.ok) {{ showLicense(r.license_key, email); return; }}
if (r.ok) {{ showLicense(r.license_key, r.invoice_id); return; }}
if (!r.fallThrough) {{
throw new Error(r.msg || 'Code rejected');
}}
@@ -594,11 +819,141 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
product_slug = product_slug,
product_description = product_description,
price_sats_fmt = price_sats_fmt,
tiers_html = tiers_html,
slug_json = serde_json::to_string(&product.slug).unwrap_or_else(|_| "\"\"".into()),
tiers_json = tiers_json,
initial_policy_json = serde_json::to_string(&initial_policy_slug)
.unwrap_or_else(|_| "\"\"".into()),
);
Ok(Html(body))
}
/// Build the server-rendered tier-picker HTML. Returns an empty string
/// when the product has fewer than 2 public policies (i.e., the existing
/// single-price view is sufficient).
fn render_tier_picker(
policies: &[crate::models::Policy],
initial: &Option<crate::models::Policy>,
product: &crate::models::Product,
) -> String {
if policies.len() < 2 {
return String::new();
}
let n = policies.len().min(4);
let class_n = match n {
2 => "tiers-2",
3 => "tiers-3",
_ => "tiers-4",
};
let cards: Vec<String> = policies
.iter()
.map(|p| {
let name = html_escape(&p.name);
let slug_attr = html_escape(&p.slug);
let price = p.price_sats_override.unwrap_or(product.price_sats);
let price_fmt = format_thousands(price);
let description = p
.metadata
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
let description_html = if description.is_empty() {
String::new()
} else {
format!(
"<p class=\"tier-description\">{}</p>",
html_escape(description)
)
};
let highlighted = p
.metadata
.get("highlight")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let selected = initial
.as_ref()
.map(|ip| ip.slug == p.slug)
.unwrap_or(false);
let entitlements_html = if p.entitlements.is_empty() {
String::new()
} else {
let lis: Vec<String> = p
.entitlements
.iter()
.map(|e| format!("<li>{}</li>", html_escape(e)))
.collect();
format!("<ul class=\"tier-entitlements\">{}</ul>", lis.join(""))
};
let dur_html = if p.duration_seconds > 0 {
let days = p.duration_seconds / 86_400;
if days > 0 {
format!("<div class=\"tier-meta\">{} days</div>", days)
} else {
let hours = p.duration_seconds / 3600;
format!("<div class=\"tier-meta\">{} hours</div>", hours.max(1))
}
} else {
"<div class=\"tier-meta\">Perpetual</div>".to_string()
};
let mut classes = String::from("tier");
if selected {
classes.push_str(" selected");
}
if highlighted {
classes.push_str(" highlighted");
}
let popular_pill = if highlighted {
"<div class=\"tier-popular\">Most popular</div>"
} else {
""
};
let trial_meta = if p.is_trial {
"<div class=\"tier-meta\" style=\"color:var(--gold-700); font-weight:600\">Trial</div>".to_string()
} else {
String::new()
};
format!(
r#"<div class="{classes}" data-policy-slug="{slug}">{popular_pill}<div class="tier-name">{name}</div><div class="tier-price">{price_fmt}<span class="tier-price-unit">sats</span></div>{dur_html}{trial_meta}{description_html}{entitlements_html}<button type="button" class="tier-select-btn">Select</button></div>"#,
classes = classes,
slug = slug_attr,
popular_pill = popular_pill,
name = name,
price_fmt = price_fmt,
dur_html = dur_html,
trial_meta = trial_meta,
description_html = description_html,
entitlements_html = entitlements_html,
)
})
.collect();
format!(
"<div class=\"tiers {n_cls}\">{cards}</div>",
n_cls = class_n,
cards = cards.join("")
)
}
/// Build the JS-side TIERS map that the buy page uses to update the price
/// card and submit the right `policy_slug`. Empty object when no public
/// policies exist (script falls back to product price unchanged).
fn build_tiers_json(
policies: &[crate::models::Policy],
product: &crate::models::Product,
) -> String {
let mut map = serde_json::Map::new();
for p in policies {
let price = p.price_sats_override.unwrap_or(product.price_sats);
map.insert(
p.slug.clone(),
serde_json::json!({
"name": p.name,
"price_sats": price,
}),
);
}
serde_json::to_string(&serde_json::Value::Object(map)).unwrap_or_else(|_| "{}".to_string())
}
fn not_found_html(slug: &str) -> String {
let slug_safe = html_escape(slug);
format!(
+129 -2
View File
@@ -51,6 +51,9 @@ pub async fn create(
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// Tier-cap gate: Creator caps at 5 active discount codes.
crate::api::tier::enforce_code_cap(&state).await?;
// Resolve product/policy slugs to ids if supplied.
let product_id = if let Some(slug) = req.product_slug.as_deref() {
let p = repo::get_product_by_slug(&state.db, slug)
@@ -148,6 +151,81 @@ pub async fn get_one(
})))
}
/// Patch fields on a discount code. Only mutable fields are accepted —
/// `code`, `kind`, `applies_to_product`, `applies_to_policy` are
/// intentionally not editable to avoid silently invalidating links that
/// have already been distributed. To change those, disable the existing
/// code and create a new one. All fields are optional; `null` clears
/// the field where the column is nullable (max_uses, expires_at,
/// referrer_label).
#[derive(Debug, Deserialize)]
pub struct UpdateDiscountCodeReq {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount: Option<i64>,
/// Use `Some(Some(n))` to set a cap, `Some(null)` to clear.
#[serde(default, deserialize_with = "deser_double_option", skip_serializing_if = "Option::is_none")]
pub max_uses: Option<Option<i64>>,
#[serde(default, deserialize_with = "deser_double_option", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, deserialize_with = "deser_double_option", skip_serializing_if = "Option::is_none")]
pub referrer_label: Option<Option<String>>,
}
/// Helper for `Option<Option<T>>` with serde — distinguishes "not present in
/// JSON" from "present but null". Used by PATCH endpoints that need to
/// clear nullable columns explicitly.
fn deser_double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Option::<T>::deserialize(de).map(Some)
}
pub async fn update(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<UpdateDiscountCodeReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let updated = repo::update_discount_code(
&state.db,
&id,
req.amount,
req.max_uses,
req.expires_at.as_ref().map(|opt| opt.as_deref()),
req.description.as_deref(),
req.referrer_label.as_ref().map(|opt| opt.as_deref()),
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"discount_code.update",
Some("discount_code"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"amount": req.amount,
"max_uses": req.max_uses,
"expires_at": req.expires_at,
"description": req.description,
"referrer_label": req.referrer_label,
}),
)
.await;
Ok(Json(json!(updated)))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
@@ -239,6 +317,12 @@ pub async fn delete(
pub struct PreviewQuery {
pub code: String,
pub product: String,
/// Optional tier slug. When set, the preview computes the discount
/// against the policy's effective price (price_sats_override, falling
/// back to product.price_sats), and validates that the code's
/// applies_to_policy_id (if any) matches the chosen tier.
#[serde(default)]
pub policy_slug: Option<String>,
}
/// PUBLIC endpoint — buyers hit this from the buy page when they click
@@ -260,6 +344,15 @@ pub async fn preview(
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product)))?;
// Resolve the chosen tier (if any). Lets the preview reflect the actual
// sat amount the buyer will see for that tier, AND lets us reject a
// code that's restricted to a different tier early.
let chosen_policy = if let Some(ps) = q.policy_slug.as_deref().filter(|s| !s.is_empty()) {
repo::get_policy_by_slug(&state.db, &product.id, ps).await?
} else {
None
};
let code = match repo::get_discount_code_by_code(&state.db, code_str).await? {
Some(c) => c,
None => {
@@ -301,6 +394,18 @@ pub async fn preview(
})));
}
}
if let Some(restricted_pid) = &code.applies_to_policy_id {
if let Some(chosen) = &chosen_policy {
if restricted_pid != &chosen.id {
return Ok(Json(json!({
"valid": false,
"reason": "wrong_tier",
"message": "This code does not apply to the selected tier.",
"base_price_sats": chosen.price_sats_override.unwrap_or(product.price_sats),
})));
}
}
}
if let Some(max) = code.max_uses {
if code.used_count >= max {
return Ok(Json(json!({
@@ -312,8 +417,12 @@ pub async fn preview(
}
}
// Compute the discounted price (mirroring purchase.rs's logic).
let base = product.price_sats;
// Compute the discounted price (mirroring purchase.rs's logic). Uses
// the chosen tier's effective price if a policy_slug was supplied.
let base = chosen_policy
.as_ref()
.and_then(|p| p.price_sats_override)
.unwrap_or(product.price_sats);
let (final_price, discount_applied) = match code.kind.as_str() {
"free_license" => (0i64, base),
"percent" => {
@@ -326,6 +435,17 @@ pub async fn preview(
let discount = code.amount.max(0).min(base);
((base - discount).max(1), discount)
}
// 'set_price' = the buyer pays exactly this many sats (regardless of
// the product's base price). If amount is >= base, the code provides
// no benefit and the buyer pays base price.
"set_price" => {
let target = code.amount.max(0);
if target >= base {
(base, 0)
} else {
((target).max(1), base - target)
}
}
_ => (base, 0),
};
@@ -348,6 +468,13 @@ pub async fn preview(
"free_license" => "Free license — no payment required.".to_string(),
"percent" => format!("{}% off applied.", code.amount as f64 / 100.0),
"fixed_sats" => format!("{} sats off applied.", code.amount),
"set_price" => {
if code.amount >= base {
"Code applied — but it doesn't lower the price for this product.".to_string()
} else {
format!("Flat price applied: {} sats.", code.amount)
}
}
_ => "Code applied.".to_string(),
},
})))
+16 -1
View File
@@ -1,4 +1,4 @@
//! Admin-only issuer-key import endpoint.
//! Issuer-key endpoints — public read of the public key, admin-only import.
//!
//! Used exactly once, by exactly one operator: when bootstrapping a
//! "master Keysat" instance (the one that issues licenses for the Keysat
@@ -148,3 +148,18 @@ pub async fn import(
the previous keypair."
})))
}
/// PUBLIC: GET /v1/issuer/public-key — returns the daemon's signing
/// public key in PEM and a couple of conveniences. No auth required —
/// the public key is, by definition, public. Used by SDK consumers and
/// by the admin Overview's "Embed your public key" tip card.
pub async fn public(
axum::extract::State(state): axum::extract::State<crate::api::AppState>,
) -> Json<serde_json::Value> {
Json(json!({
"public_key_pem": state.keypair.public_key_pem,
"key_algorithm": "ed25519",
"key_format_version": crate::crypto::KEY_VERSION,
}))
}
+112 -9
View File
@@ -26,6 +26,8 @@
//! | POST | `/v1/admin/licenses` | manually issue license (comp/dev) |
//! | GET | `/v1/admin/licenses` | list licenses by product |
//! | GET | `/v1/admin/licenses/search` | search by email / npub / invoice |
//! | GET | `/v1/admin/licenses/summary` | aggregate counts (total/active/24h/7d) |
//! | GET | `/v1/admin/revenue/summary` | lifetime / 30d / 7d / 24h sats earned |
//! | POST | `/v1/admin/licenses/:id/revoke` | revoke a license |
//! | POST | `/v1/admin/licenses/:id/suspend` | suspend (reversible) |
//! | POST | `/v1/admin/licenses/:id/unsuspend` | unsuspend |
@@ -42,12 +44,18 @@
//! | GET | `/v1/admin/discount-codes` | list discount codes |
//! | GET | `/v1/admin/discount-codes/:id` | one code with redemption history |
//! | PATCH | `/v1/admin/discount-codes/:id/active` | enable / disable code |
//! | PATCH | `/v1/admin/discount-codes/:id` | edit amount / max_uses / expires / desc |
//! | DELETE | `/v1/admin/discount-codes/:id` | hard-delete (refused if redeemed) |
//! | GET | `/v1/discount-codes/preview` | PUBLIC: preview discount on a product |
//! | GET | `/v1/admin/audit` | list audit log entries |
//! | POST | `/admin/login` | PUBLIC: web UI password login (sets cookie) |
//! | POST | `/admin/logout` | clear session cookie |
//! | GET | `/admin/login/status` | PUBLIC: {has_password, logged_in} |
//! | POST | `/v1/admin/web-password` | admin-only: set/rotate web UI password |
pub mod admin;
pub mod admin_ui;
pub mod auth;
pub mod btcpay_authorize;
pub mod discount_codes;
pub mod machines;
@@ -58,6 +66,8 @@ pub mod buy_page;
pub mod issuer_key;
pub mod redeem;
pub mod self_license;
pub mod session_layer;
pub mod tier;
pub mod validate;
pub mod webhook;
pub mod webhook_endpoints;
@@ -206,6 +216,10 @@ pub fn router(state: AppState) -> Router {
get(btcpay_authorize::payment_methods),
)
.route("/v1/admin/products", post(admin::create_product))
.route(
"/v1/admin/products/:id",
patch(admin::update_product).delete(admin::delete_product),
)
.route(
"/v1/admin/products/:id/active",
patch(admin::set_product_active),
@@ -220,6 +234,18 @@ pub fn router(state: AppState) -> Router {
"/v1/admin/licenses/search",
get(admin::search_licenses),
)
.route(
"/v1/admin/licenses/summary",
get(admin::licenses_summary),
)
.route(
"/v1/admin/licenses/counts",
get(admin::license_counts),
)
.route(
"/v1/admin/revenue/summary",
get(admin::revenue_summary),
)
.route(
"/v1/admin/licenses/:id/revoke",
post(admin::revoke_license),
@@ -237,14 +263,27 @@ pub fn router(state: AppState) -> Router {
"/v1/admin/policies",
get(policies::list).post(policies::create),
)
.route(
"/v1/admin/policies/:id",
patch(policies::update).delete(policies::delete),
)
.route(
"/v1/admin/policies/:id/active",
patch(policies::set_active),
)
.route(
"/v1/admin/policies/:id/public",
patch(policies::set_public),
)
.route(
"/v1/admin/policies/:id/tip",
patch(policies::set_tip),
)
// Public tier listing — drives the /buy/<slug> tier picker.
.route(
"/v1/products/:slug/policies",
get(policies::list_public_policies),
)
.route("/v1/admin/tips", get(policies::list_tips))
// Machines (admin views).
.route("/v1/admin/machines", get(machines::admin_list))
@@ -272,7 +311,9 @@ pub fn router(state: AppState) -> Router {
)
.route(
"/v1/admin/discount-codes/:id",
get(discount_codes::get_one).delete(discount_codes::delete),
get(discount_codes::get_one)
.patch(discount_codes::update)
.delete(discount_codes::delete),
)
.route(
"/v1/admin/discount-codes/:id/active",
@@ -300,6 +341,23 @@ pub fn router(state: AppState) -> Router {
// Issuer-key import — admin-only, master-bootstrap path. No
// StartOS Action surface; documented in MASTER_KEYPAIR_PROCEDURE.md.
.route("/v1/admin/import-issuer-key", post(issuer_key::import))
// 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))
// 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+).
.route("/admin/login", post(auth::login))
.route("/admin/logout", post(auth::logout))
.route("/admin/login/status", get(auth::login_status))
.route("/v1/admin/web-password", post(auth::set_password))
// Bridge cookie-based sessions onto the existing API-key require_admin
// guard. Has to be the last layer so it runs first (axum applies
// layers in reverse-of-declaration order).
.layer(axum::middleware::from_fn_with_state(
state.clone(),
session_layer::session_to_bearer,
))
.with_state(state)
}
@@ -461,6 +519,15 @@ h1 {{
border-radius:7px; padding:8px 12px;
color:var(--ink-700); text-align:center;
}}
.invoice-ref {{
margin-top:12px; padding:8px 12px;
font-family:var(--font-mono); font-size:11.5px;
color:var(--ink-500); text-align:center;
}}
.invoice-ref code {{
background:var(--cream-100); border:1px solid var(--border-1);
padding:1px 6px; border-radius:5px; color:var(--ink-700);
}}
.license-success h2 {{
font-family:var(--font-display); font-weight:500; font-size:22px;
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em; text-align:center;
@@ -521,16 +588,17 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
<div class="pending-card" id="pending-card">
<div class="stamp">&mdash; Awaiting confirmation &mdash;</div>
<h2>Hang tight.</h2>
<p class="sub">This page will refresh automatically when your license is ready.</p>
<p class="sub">This page will refresh automatically when your license is ready. Safe to bookmark this URL and come back later — your license will be here.</p>
<div class="spinner" aria-hidden="true"></div>
<div class="status-detail" id="status-detail">checking status&hellip;</div>
<div class="invoice-ref" id="invoice-ref"></div>
</div>
<!-- success state: license card -->
<div class="license-success hide" id="license-success" role="region" aria-label="License issued">
<div class="stamp">&mdash; License issued &mdash;</div>
<h2>You&rsquo;re licensed.</h2>
<p class="sub">Your signed license is below. We&rsquo;ll also email a copy.</p>
<p class="sub">Your signed license is below. Save it before closing this tab.</p>
<div class="field-label">License key</div>
<div class="key-box">
<span class="key-text" id="license-key-text">&hellip;</span>
@@ -569,6 +637,11 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
const errorMsg = document.getElementById('error-msg');
const pageTitle = document.getElementById('page-title');
const pageLede = document.getElementById('page-lede');
const invoiceRef = document.getElementById('invoice-ref');
if (invoiceRef) {{
invoiceRef.innerHTML = 'Reference for support: <code>' +
INVOICE_ID.replace(/[<>&]/g, '') + '</code>';
}}
// Copy button.
document.getElementById('license-key-copy').addEventListener('click', async function() {{
@@ -596,8 +669,35 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
pageLede.textContent = 'See the message below for details.';
}}
// Adaptive polling: tight cadence for the first 2 minutes (most invoices
// settle within one block), then back off so a slow block + clearnet flake
// doesn't burn battery/data on the buyer's phone. URL is bookmark-friendly:
// a buyer can close this tab and return any time — polling resumes from
// wherever the invoice currently is.
let attempt = 0;
const MAX_ATTEMPTS = 240; // 240 * 3s = 12 min total. Most settle inside 1.
let elapsedMs = 0;
const TIGHT_MS = 3000; // 02 min → poll every 3s
const MED_MS = 10000; // 210 min → poll every 10s
const SLOW_MS = 30000; // 1030 min→ poll every 30s
const TIGHT_DEADLINE = 2 * 60 * 1000;
const MED_DEADLINE = 10 * 60 * 1000;
const HARD_DEADLINE = 30 * 60 * 1000;
function nextDelay() {{
if (elapsedMs < TIGHT_DEADLINE) return TIGHT_MS;
if (elapsedMs < MED_DEADLINE) return MED_MS;
return SLOW_MS;
}}
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.';
return 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.';
}}
return 'invoice status: ' + (status || 'pending');
}}
async function poll() {{
attempt++;
@@ -615,9 +715,9 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
return showSuccess(j.license_key);
}}
const status = j.status || 'pending';
statusDetail.textContent = 'invoice status: ' + status + (attempt > 1 ? ' (still polling)' : '');
statusDetail.textContent = waitingCopy(status);
if (status === 'expired' || status === 'invalid') {{
return showError('Payment was not completed (status: ' + status + '). If you sent funds, contact the seller.');
return showError('Payment was not completed (status: ' + status + '). If you sent funds, contact the seller and reference your invoice id above.');
}}
scheduleNext();
}} catch (err) {{
@@ -626,11 +726,14 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
}}
}}
function scheduleNext() {{
if (attempt >= MAX_ATTEMPTS) {{
statusDetail.textContent = 'still waiting — refresh the page or come back later.';
if (elapsedMs >= HARD_DEADLINE) {{
statusDetail.textContent =
'still waiting after 30 minutes. Bookmark this URL and refresh in a few minutes — your license will appear automatically once the invoice settles. If you still see this in an hour, contact the seller and reference the invoice id at the top of this page.';
return;
}}
setTimeout(poll, 3000);
const d = nextDelay();
elapsedMs += d;
setTimeout(poll, d);
}}
poll();
}})();
+326 -1
View File
@@ -42,7 +42,7 @@ pub struct CreatePolicyReq {
pub entitlements: Vec<String>,
#[serde(default)]
pub metadata: Value,
/// Optional Lightning recipient (e.g. "tip@keysat.xyz") to tip a percentage
/// Optional Lightning recipient (e.g. "keysat@primal.net") to tip a percentage
/// of each successful issuance to. None = no tipping.
#[serde(default)]
pub tip_recipient: Option<String>,
@@ -69,6 +69,9 @@ pub async fn create(
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product_slug)))?;
// Tier-cap gate: Creator caps at 5 policies per product.
crate::api::tier::enforce_policy_cap(&state, &product.id).await?;
if req.duration_seconds < 0 {
return Err(AppError::BadRequest("duration_seconds must be >= 0".into()));
}
@@ -182,6 +185,328 @@ pub async fn set_active(
Ok(Json(json!({ "ok": true })))
}
#[derive(Debug, Deserialize)]
pub struct PolicyDeleteOpts {
#[serde(default)]
pub force: bool,
}
/// Hard-delete a policy. Two modes:
///
/// - **Safe (default)**: refuses if any invoice or license references
/// the policy. Operator should use Hide / Disable instead in that case.
///
/// - **Force (`?force=true`)**: cascades through machines → redemptions →
/// licenses → invoices for that policy_id before removing the policy.
/// Audit-logged with cascade counts.
pub async fn delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Query(opts): Query<PolicyDeleteOpts>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let policy = repo::get_policy_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("policy '{id}'")))?;
let invoice_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE policy_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
let license_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE policy_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
if !opts.force && invoice_count + license_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
)));
}
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
};
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 = ?)",
)
.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?;
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)
.await?;
tx.commit().await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
if opts.force { "policy.force_delete" } else { "policy.delete" },
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"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_machines": machine_count,
"cascaded_redemptions": redemption_count,
}),
)
.await;
Ok(Json(json!({
"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_machines": machine_count,
"cascaded_redemptions": redemption_count,
})))
}
/// Patch mutable fields on a policy. Slug + product are NOT editable —
/// they're identifiers operators may have hard-coded into integration
/// docs or buy URLs. Tip config has its own dedicated endpoint
/// (`PATCH /v1/admin/policies/:id/tip`).
#[derive(Debug, Deserialize)]
pub struct UpdatePolicyReq {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub duration_seconds: Option<i64>,
#[serde(default)]
pub grace_seconds: Option<i64>,
#[serde(default)]
pub max_machines: Option<i64>,
#[serde(default)]
pub is_trial: Option<bool>,
/// Use `Some(Some(n))` to set a tier price, `Some(null)` to clear and
/// fall back to the product's base price.
#[serde(default, deserialize_with = "deser_double_option_i64", skip_serializing_if = "Option::is_none")]
pub price_sats_override: Option<Option<i64>>,
#[serde(default)]
pub entitlements: Option<Vec<String>>,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
}
fn deser_double_option_i64<'de, D>(de: D) -> Result<Option<Option<i64>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<i64>::deserialize(de).map(Some)
}
pub async fn update(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<UpdatePolicyReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if let Some(d) = req.duration_seconds {
if d < 0 {
return Err(AppError::BadRequest("duration_seconds must be >= 0".into()));
}
}
if let Some(g) = req.grace_seconds {
if g < 0 {
return Err(AppError::BadRequest("grace_seconds must be >= 0".into()));
}
}
if let Some(m) = req.max_machines {
if m < 0 {
return Err(AppError::BadRequest("max_machines must be >= 0".into()));
}
}
let updated = repo::update_policy(
&state.db,
&id,
req.name.as_deref(),
req.duration_seconds,
req.grace_seconds,
req.max_machines,
req.is_trial,
req.price_sats_override,
req.entitlements.as_deref(),
req.metadata.as_ref(),
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.update",
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"name": req.name,
"duration_seconds": req.duration_seconds,
"max_machines": req.max_machines,
"price_sats_override": req.price_sats_override,
"entitlements": req.entitlements,
}),
)
.await;
Ok(Json(json!(updated)))
}
#[derive(Debug, Deserialize)]
pub struct SetPublicReq {
pub public: bool,
}
/// Toggle whether a policy is rendered as a tier-card on /buy/<slug>.
/// Private policies remain usable from admin issuance, but are excluded
/// from the public tier picker.
pub async fn set_public(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetPublicReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_policy_public(&state.db, &id, req.public).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.set_public",
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "public": req.public }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
// ---------- Public buyer endpoint ----------
/// Public (no-auth): `GET /v1/products/:slug/policies` — used by the buy
/// page tier picker. Returns the product (slug, name, description, base
/// price) and an array of active+public policies, each with the fields a
/// buyer needs to decide between tiers (name, slug, description from
/// metadata, price_sats, duration_seconds, max_machines, is_trial,
/// entitlements). Internal/admin fields (id, tip recipient, raw metadata,
/// created_at) are deliberately omitted.
pub async fn list_public_policies(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> AppResult<Json<Value>> {
let product = repo::get_product_by_slug(&state.db, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{slug}'")))?;
if !product.active {
return Err(AppError::NotFound(format!("product '{slug}'")));
}
let policies = repo::list_public_policies_by_product(&state.db, &product.id).await?;
let policies_json: Vec<Value> = policies
.into_iter()
.map(|p| {
// Description: pulled from metadata.description if present, so
// operators can write a buyer-friendly per-tier blurb without a
// schema change. Falls back to "" if absent.
let description = p
.metadata
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Highlight: same pattern — metadata.highlight = true marks the
// "most popular" tier so the buy page can render a gold ribbon.
let highlighted = p
.metadata
.get("highlight")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let price_sats = p.price_sats_override.unwrap_or(product.price_sats);
json!({
"slug": p.slug,
"name": p.name,
"description": description,
"price_sats": price_sats,
"duration_seconds": p.duration_seconds,
"max_machines": p.max_machines,
"is_trial": p.is_trial,
"entitlements": p.entitlements,
"highlighted": highlighted,
})
})
.collect();
Ok(Json(json!({
"product": {
"slug": product.slug,
"name": product.name,
"description": product.description,
"base_price_sats": product.price_sats,
},
"policies": policies_json,
})))
}
#[derive(Debug, Deserialize)]
pub struct SetTipReq {
/// Lightning Address (`user@domain`). Pass `null` to disable tipping.
+174 -5
View File
@@ -31,17 +31,34 @@ pub struct StartPurchaseReq {
pub redirect_url: Option<String>,
/// Optional discount / referral code (case-insensitive).
pub code: Option<String>,
/// Optional tier (policy slug). When set, the policy's
/// `price_sats_override` becomes the base price (if defined), and the
/// chosen policy is remembered on the invoice so it's used at license
/// issuance time. When omitted, the daemon falls back to the product's
/// default policy at issuance — same as pre-:27 behaviour.
pub policy_slug: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct StartPurchaseResp {
pub invoice_id: String, // our internal id
pub btcpay_invoice_id: String, // BTCPay's id (for debugging)
pub checkout_url: String, // URL the user opens to pay
/// Empty for the free-tier shortcut path (price = 0 after override/discount):
/// we synthesize a settled invoice locally and skip BTCPay entirely.
pub btcpay_invoice_id: String,
/// Non-empty on the paid path. On the free path, empty — the buyer should
/// be shown the license card directly using `license_key` below.
pub checkout_url: String,
pub amount_sats: i64, // what BTCPay was charged (post-discount)
pub base_price_sats: i64, // product list price (pre-discount)
pub discount_applied_sats: i64, // base - amount_sats; 0 if no code
pub poll_url: String, // where to check status
/// Set when the daemon issued the license inline (free tier or 100%-off).
/// When present, the client should display the license card directly
/// instead of redirecting to a BTCPay checkout.
#[serde(skip_serializing_if = "Option::is_none")]
pub license_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_id: Option<String>,
}
/// Floor for invoiced amount after a discount is applied. Set to 1 sat so
@@ -64,7 +81,38 @@ pub async fn start(
)));
}
let base_price = product.price_sats;
// Resolve the optional tier (policy_slug). The chosen policy must be
// active and public for it to be selectable from the public buy page.
// (The admin can still issue under non-public policies via /v1/admin/licenses.)
let chosen_policy = if let Some(ps) = req.policy_slug.as_deref().filter(|s| !s.is_empty()) {
let pol = repo::get_policy_by_slug(&state.db, &product.id, ps)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{ps}' for product '{}'",
req.product
))
})?;
if !pol.active {
return Err(AppError::BadRequest(format!(
"policy '{ps}' is not active"
)));
}
if !pol.public {
return Err(AppError::BadRequest(format!(
"policy '{ps}' is not available on the public buy page"
)));
}
Some(pol)
} else {
None
};
// Effective base price: policy override if set, else product price.
let base_price = chosen_policy
.as_ref()
.and_then(|p| p.price_sats_override)
.unwrap_or(product.price_sats);
// Resolve and validate the discount code if one was supplied. The
// ordering here matters: we must atomically reserve a counter slot
@@ -103,8 +151,19 @@ pub async fn start(
));
}
}
// Note: applies_to_policy_id is informational in v0.1 — the
// policy used at license-issuance time is the product's default.
// If the code is restricted to a specific policy and a tier was
// selected, they must match. If no tier was selected, the code is
// implicitly applied to the product's default policy at issuance
// time, which we accept here (v0.1.0:27+).
if let Some(restricted_pid) = &code.applies_to_policy_id {
if let Some(chosen) = &chosen_policy {
if restricted_pid != &chosen.id {
return Err(AppError::BadRequest(
"discount code does not apply to the selected tier".into(),
));
}
}
}
// Step B: atomic reserve.
repo::try_reserve_code_slot(&state.db, &code.id).await?;
@@ -116,6 +175,101 @@ pub async fn start(
(base_price, None, 0)
};
// ----- Free-tier shortcut -----
// If the post-discount, post-policy-override price came out at 0 sats
// (price_sats_override = 0 on a "free" tier, OR a 100%-off discount on
// a paid tier), skip BTCPay entirely. BTCPay refuses 0-sat invoices and
// would also waste a UI step that prompts the buyer to "pay" zero. We
// synthesize a settled invoice locally, issue the license inline, and
// return the signed key in the response. The buy page renders the
// license card directly.
if final_price <= 0 {
let free_invoice = repo::create_free_invoice(
&state.db,
&product.id,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
chosen_policy.as_ref().map(|p| p.id.as_str()),
)
.await
.map_err(|e| {
// If we got a code reservation earlier, release it.
let pool = state.db.clone();
let code = reservation.clone();
tokio::spawn(async move {
if let Some(c) = code {
let _ = repo::release_code_slot(&pool, &c.id).await;
}
});
e
})?;
// If a discount code was applied, record the redemption.
if let Some(code) = &reservation {
let _ = repo::record_pending_redemption(
&state.db,
&code.id,
&free_invoice.id,
discount_applied,
base_price,
0,
)
.await;
}
// Issue the license. This finalizes the redemption row and fires
// license.issued + (if applicable) code.redeemed webhooks.
let license_id =
crate::api::webhook::issue_license_for_invoice(&state, &free_invoice).await?;
// Re-derive the signed key (same pattern as redeem.rs / status()).
let lic = repo::get_license_by_invoice(&state.db, &free_invoice.id)
.await?
.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!("license vanished after issue"))
})?;
let flags = if lic.is_trial { FLAG_TRIAL } else { 0 };
let expires_at_unix = lic
.expires_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.timestamp())
.unwrap_or(0);
let payload = LicensePayload {
version: KEY_VERSION_V2,
flags,
product_id: uuid::Uuid::parse_str(&lic.product_id)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad product_id: {e}")))?,
license_id: uuid::Uuid::parse_str(&lic.id)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad license_id: {e}")))?,
issued_at: chrono::DateTime::parse_from_rfc3339(&lic.issued_at)
.map(|t| t.timestamp())
.unwrap_or(0),
expires_at: expires_at_unix,
fingerprint_hash: [0u8; 32],
entitlements: lic.entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
let license_key = encode_key(&payload, &sig);
let poll_url = format!(
"{}/v1/purchase/{}",
state.config.public_base_url, free_invoice.id
);
return Ok(Json(StartPurchaseResp {
invoice_id: free_invoice.id.clone(),
btcpay_invoice_id: free_invoice.btcpay_invoice_id.clone(), // "free-<uuid>"
checkout_url: String::new(), // signal: no BTCPay
amount_sats: 0,
base_price_sats: base_price,
discount_applied_sats: discount_applied,
poll_url,
license_key: Some(license_key),
license_id: Some(license_id),
}));
}
// Pre-allocate an internal invoice id so we can pass it to BTCPay as
// metadata, letting us correlate webhook events back to our row even
// before we've persisted the BTCPay invoice id.
@@ -204,6 +358,7 @@ pub async fn start(
&checkout_url,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
chosen_policy.as_ref().map(|p| p.id.as_str()),
)
.await
{
@@ -254,6 +409,8 @@ pub async fn start(
base_price_sats: base_price,
discount_applied_sats: discount_applied,
poll_url,
license_key: None,
license_id: None,
}))
}
@@ -269,6 +426,18 @@ fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 {
((base * bps) / 10_000).max(0).min(base) as i64
}
"fixed_sats" => amount.max(0).min(base_price_sats),
// 'set_price' = the buyer pays exactly `amount` sats regardless of
// base price. Compute it as a discount: subtract enough to land at
// `amount`. If `amount >= base_price_sats`, the code provides no
// benefit (discount = 0).
"set_price" => {
let target = amount.max(0);
if target >= base_price_sats {
0
} else {
base_price_sats - target
}
}
_ => 0,
}
}
+39
View File
@@ -33,6 +33,10 @@ pub struct RedeemReq {
pub buyer_email: Option<String>,
/// Optional free-text note (recorded on invoice).
pub buyer_note: Option<String>,
/// Optional tier (policy slug). Same semantics as the purchase flow:
/// when set, the chosen public+active policy is remembered on the
/// invoice so its entitlements/expiry are baked into the license.
pub policy_slug: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -86,6 +90,40 @@ pub async fn redeem(
}
}
// Resolve and validate the optional tier.
let chosen_policy = if let Some(ps) = req.policy_slug.as_deref().filter(|s| !s.is_empty()) {
let pol = repo::get_policy_by_slug(&state.db, &product.id, ps)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{ps}' for product '{}'",
req.product
))
})?;
if !pol.active {
return Err(AppError::BadRequest(format!(
"policy '{ps}' is not active"
)));
}
if !pol.public {
return Err(AppError::BadRequest(format!(
"policy '{ps}' is not available on the public buy page"
)));
}
Some(pol)
} else {
None
};
if let Some(restricted_pid) = &code.applies_to_policy_id {
if let Some(chosen) = &chosen_policy {
if restricted_pid != &chosen.id {
return Err(AppError::BadRequest(
"code does not apply to the selected tier".into(),
));
}
}
}
// Atomic reserve. If reserved succeeds and any subsequent step fails,
// we release the slot so a freed slot becomes available again.
repo::try_reserve_code_slot(&state.db, &code.id).await?;
@@ -96,6 +134,7 @@ pub async fn redeem(
&product.id,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
chosen_policy.as_ref().map(|p| p.id.as_str()),
)
.await
{
@@ -0,0 +1,50 @@
//! Bridges cookie-based web-UI sessions onto the existing API-key
//! `require_admin` guard.
//!
//! When an incoming request has no `Authorization` header but does carry
//! a valid `keysat_session` cookie, this middleware injects
//! `Authorization: Bearer <api_key>` on the request. Downstream the
//! `require_admin` guard sees a bearer token and treats the call as
//! authenticated — no per-handler changes required.
//!
//! Public endpoints (buy page, /v1/purchase, /v1/redeem, /v1/validate,
//! /v1/issuer/public-key, etc.) don't read the Authorization header, so
//! injecting it for them is benign — and the middleware short-circuits
//! anyway when there's no session cookie present.
use crate::api::AppState;
use crate::db::repo;
use axum::{
extract::{Request, State},
http::{header, HeaderValue},
middleware::Next,
response::Response,
};
pub async fn session_to_bearer(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Response {
// Fast path: caller already supplied an Authorization header — leave
// the request alone.
if req.headers().contains_key(header::AUTHORIZATION) {
return next.run(req).await;
}
// Fast path: no cookie at all.
let token = match crate::api::auth::extract_session_cookie(req.headers()) {
Some(t) => t,
None => return next.run(req).await,
};
// DB hit only when there IS a session cookie.
let valid = repo::is_session_valid(&state.db, &token)
.await
.unwrap_or(false);
if valid {
let api_key = state.config.admin_api_key.clone();
if let Ok(hv) = HeaderValue::from_str(&format!("Bearer {api_key}")) {
req.headers_mut().insert(header::AUTHORIZATION, hv);
}
}
next.run(req).await
}
+238
View File
@@ -0,0 +1,238 @@
//! Tier model + entitlement-cap enforcement.
//!
//! 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.
//! - **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.
//! - **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.
//!
//! 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.
//!
//! 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
//! a "Upgrade to Pro" CTA right inside the error.
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.
pub const CREATOR_PRODUCT_CAP: i64 = 5;
pub const CREATOR_POLICY_CAP_PER_PRODUCT: i64 = 5;
pub const CREATOR_CODE_CAP: i64 = 5;
/// Where the upgrade banner / 402 error sends an operator to buy a
/// higher tier. Hard-coded to the canonical master Keysat. Eventually
/// this becomes configurable for partners who run their own master
/// Keysat (resellers); for v0.1 it's fixed.
pub const UPGRADE_URL_PRO: &str = "https://licensing.keysat.xyz/buy/keysat?policy=pro";
pub const UPGRADE_URL_PATRON: &str = "https://licensing.keysat.xyz/buy/keysat?policy=patron";
/// Snapshot of the daemon's current entitlements + a coarse tier label
/// for UI consumption.
#[derive(Debug, Clone)]
pub struct TierInfo {
/// Coarse label: "creator" | "pro" | "patron" | "unlicensed".
pub label: &'static str,
/// Display-friendly name: "Creator" | "Pro" | "Patron" | "Unlicensed".
pub display_name: &'static str,
/// The full entitlement set baked into the self-license, or empty if unlicensed.
pub entitlements: Vec<String>,
}
impl TierInfo {
pub fn has(&self, name: &str) -> bool {
self.entitlements.iter().any(|e| e == name)
}
pub fn is_at_least_pro(&self) -> bool {
// Anything with unlimited_products is Pro or above. Patron is the
// top tier; the `patron` entitlement is purely a badge and doesn't
// grant anything Pro doesn't.
self.has("unlimited_products")
}
}
/// 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).
pub async fn current(state: &AppState) -> TierInfo {
let tier = state.self_tier.read().await;
let entitlements = match &*tier {
Tier::Licensed { entitlements, .. } => entitlements.clone(),
Tier::Unlicensed { .. } => Vec::new(),
};
drop(tier);
let label: &'static str;
let display_name: &'static str;
if entitlements.iter().any(|e| e == "patron") {
label = "patron";
display_name = "Patron";
} else if entitlements.iter().any(|e| e == "unlimited_products") {
label = "pro";
display_name = "Pro";
} else if entitlements.iter().any(|e| e == "self_host") {
label = "creator";
display_name = "Creator";
} else {
label = "unlicensed";
display_name = "Unlicensed";
}
TierInfo {
label,
display_name,
entitlements,
}
}
/// Admin endpoint: GET /v1/admin/tier — used by the SPA's persistent
/// upgrade banner to know which tier message to show. Returns current
/// tier label, full entitlement list, current usage counts, and the
/// caps that apply (or null for unlimited).
pub async fn admin_status(
axum::extract::State(state): axum::extract::State<AppState>,
headers: axum::http::HeaderMap,
) -> AppResult<axum::Json<serde_json::Value>> {
crate::api::admin::require_admin(&state, &headers)?;
let tier = current(&state).await;
let product_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products")
.fetch_one(&state.db)
.await?;
let active_code_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM discount_codes WHERE active = 1")
.fetch_one(&state.db)
.await?;
let serde_json_caps = serde_json::json!({
"products": if tier.has("unlimited_products") {
serde_json::Value::Null
} else {
serde_json::Value::from(CREATOR_PRODUCT_CAP)
},
"policies_per_product": if tier.has("unlimited_policies") {
serde_json::Value::Null
} else {
serde_json::Value::from(CREATOR_POLICY_CAP_PER_PRODUCT)
},
"active_codes": if tier.has("unlimited_codes") {
serde_json::Value::Null
} else {
serde_json::Value::from(CREATOR_CODE_CAP)
},
});
let next_tier = match tier.label {
"creator" | "unlicensed" => "pro",
"pro" => "patron",
_ => "patron",
};
let upgrade_url = match next_tier {
"pro" => UPGRADE_URL_PRO,
_ => UPGRADE_URL_PATRON,
};
Ok(axum::Json(serde_json::json!({
"tier": tier.label,
"tier_name": tier.display_name,
"entitlements": tier.entitlements,
"usage": {
"products": product_count,
"active_codes": active_code_count,
},
"caps": serde_json_caps,
"next_tier": if tier.label == "patron" { serde_json::Value::Null } else { serde_json::Value::from(next_tier) },
"upgrade_url": if tier.label == "patron" { serde_json::Value::Null } else { serde_json::Value::from(upgrade_url) },
})))
}
/// Refuse a new product if the operator is at the Creator-tier product
/// cap and lacks `unlimited_products`. Counts ALL products including
/// inactive ones — operators don't get to evade the cap by toggling
/// active=false on old rows.
pub async fn enforce_product_cap(state: &AppState) -> AppResult<()> {
let tier = current(state).await;
if tier.has("unlimited_products") {
return Ok(());
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products")
.fetch_one(&state.db)
.await?;
if count >= CREATOR_PRODUCT_CAP {
return Err(AppError::PaymentRequired {
message: format!(
"Your {} tier allows up to {} products. You're at {}. Upgrade to Pro for unlimited products.",
tier.display_name, CREATOR_PRODUCT_CAP, count
),
upgrade_url: UPGRADE_URL_PRO.to_string(),
});
}
Ok(())
}
/// Refuse a new policy on `product_id` if the operator is at the
/// Creator-tier per-product policy cap and lacks `unlimited_policies`.
pub async fn enforce_policy_cap(state: &AppState, product_id: &str) -> AppResult<()> {
let tier = current(state).await;
if tier.has("unlimited_policies") {
return Ok(());
}
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM policies WHERE product_id = ?")
.bind(product_id)
.fetch_one(&state.db)
.await?;
if count >= CREATOR_POLICY_CAP_PER_PRODUCT {
return Err(AppError::PaymentRequired {
message: format!(
"Your {} tier allows up to {} policies per product. You're at {}. Upgrade to Pro for unlimited.",
tier.display_name, CREATOR_POLICY_CAP_PER_PRODUCT, count
),
upgrade_url: UPGRADE_URL_PRO.to_string(),
});
}
Ok(())
}
/// 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
/// the right behavior because disabled codes don't function.
pub async fn enforce_code_cap(state: &AppState) -> AppResult<()> {
let tier = current(state).await;
if tier.has("unlimited_codes") {
return Ok(());
}
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM discount_codes WHERE active = 1")
.fetch_one(&state.db)
.await?;
if count >= CREATOR_CODE_CAP {
return Err(AppError::PaymentRequired {
message: format!(
"Your {} tier allows up to {} active discount codes. You're at {}. Disable an old code to free up a slot, or upgrade to Pro for unlimited.",
tier.display_name, CREATOR_CODE_CAP, count
),
upgrade_url: UPGRADE_URL_PRO.to_string(),
});
}
Ok(())
}
+12 -5
View File
@@ -144,15 +144,22 @@ pub async fn issue_license_for_invoice(
state: &AppState,
invoice: &crate::models::Invoice,
) -> AppResult<String> {
// Pick the "default" policy for the product: the first active policy
// whose slug is "default" if present, else the first active policy, else
// none (perpetual, no entitlements, max_machines=1).
// Tiered pricing (v0.1.0:27+): if the invoice carries a `policy_id`, the
// buyer chose a specific tier on /buy/<slug>. Use that policy verbatim
// (its entitlements, expiry, max_machines, trial flag get baked into the
// license). Otherwise fall back to the legacy default-pick: first
// active policy whose slug is "default", else the first active, else
// no policy (perpetual, no entitlements, max_machines=1).
let policy = if let Some(pid) = invoice.policy_id.as_deref() {
repo::get_policy_by_id(&state.db, pid).await?
} else {
let policies = repo::list_policies_by_product(&state.db, &invoice.product_id, true).await?;
let policy = policies
policies
.iter()
.find(|p| p.slug == "default")
.or_else(|| policies.first())
.cloned();
.cloned()
};
let now = Utc::now();
let issued_at = now.to_rfc3339();
+391 -12
View File
@@ -100,6 +100,55 @@ pub async fn set_product_active(pool: &SqlitePool, id: &str, active: bool) -> Ap
Ok(())
}
/// Patch mutable fields on a product. `slug` and `id` are intentionally
/// not editable — slug is part of the public buy URL, and changing it
/// would break links operators have shared. Each Option is "Some →
/// update, None → leave alone."
pub async fn update_product(
pool: &SqlitePool,
id: &str,
name: Option<&str>,
description: Option<&str>,
price_sats: Option<i64>,
) -> AppResult<Product> {
let mut sets: Vec<&str> = Vec::new();
if name.is_some() {
sets.push("name = ?");
}
if description.is_some() {
sets.push("description = ?");
}
if price_sats.is_some() {
sets.push("price_sats = ?");
}
if sets.is_empty() {
return get_product_by_id(pool, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("product {id}")));
}
sets.push("updated_at = ?");
let now = Utc::now().to_rfc3339();
let sql = format!("UPDATE products SET {} WHERE id = ?", sets.join(", "));
let mut q = sqlx::query(&sql);
if let Some(v) = name {
q = q.bind(v);
}
if let Some(v) = description {
q = q.bind(v);
}
if let Some(v) = price_sats {
q = q.bind(v);
}
q = q.bind(&now).bind(id);
let rows = q.execute(pool).await?.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("product {id}")));
}
get_product_by_id(pool, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("product {id}")))
}
fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
let metadata_json: String = row.try_get("metadata_json")?;
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default();
@@ -119,6 +168,7 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
// ---------- Invoices ----------
#[allow(clippy::too_many_arguments)]
pub async fn create_invoice(
pool: &SqlitePool,
id: &str,
@@ -128,12 +178,14 @@ pub async fn create_invoice(
checkout_url: &str,
buyer_email: Option<&str>,
buyer_note: Option<&str>,
policy_id: Option<&str>,
) -> AppResult<Invoice> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO invoices
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note, amount_sats, checkout_url, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)",
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
amount_sats, checkout_url, policy_id, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(btcpay_invoice_id)
@@ -142,6 +194,7 @@ pub async fn create_invoice(
.bind(buyer_note)
.bind(amount_sats)
.bind(checkout_url)
.bind(policy_id)
.bind(&now)
.bind(&now)
.execute(pool)
@@ -162,6 +215,7 @@ pub async fn create_free_invoice(
product_id: &str,
buyer_email: Option<&str>,
buyer_note: Option<&str>,
policy_id: Option<&str>,
) -> AppResult<Invoice> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
@@ -169,14 +223,15 @@ pub async fn create_free_invoice(
sqlx::query(
"INSERT INTO invoices
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
amount_sats, checkout_url, created_at, updated_at)
VALUES (?, ?, ?, 'settled', ?, ?, 0, '', ?, ?)",
amount_sats, checkout_url, policy_id, created_at, updated_at)
VALUES (?, ?, ?, 'settled', ?, ?, 0, '', ?, ?, ?)",
)
.bind(&id)
.bind(&synthetic_btcpay_id)
.bind(product_id)
.bind(buyer_email)
.bind(buyer_note)
.bind(policy_id)
.bind(&now)
.bind(&now)
.execute(pool)
@@ -189,7 +244,7 @@ pub async fn create_free_invoice(
pub async fn get_invoice_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Invoice>> {
let row = sqlx::query(
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
amount_sats, checkout_url, created_at, updated_at
amount_sats, checkout_url, created_at, updated_at, policy_id
FROM invoices WHERE id = ?",
)
.bind(id)
@@ -204,7 +259,7 @@ pub async fn get_invoice_by_btcpay_id(
) -> AppResult<Option<Invoice>> {
let row = sqlx::query(
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
amount_sats, checkout_url, created_at, updated_at
amount_sats, checkout_url, created_at, updated_at, policy_id
FROM invoices WHERE btcpay_invoice_id = ?",
)
.bind(btcpay_invoice_id)
@@ -240,7 +295,7 @@ pub async fn list_pending_invoices(
let cutoff = (Utc::now() - chrono::Duration::hours(max_age_hours)).to_rfc3339();
let rows = sqlx::query(
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
amount_sats, checkout_url, created_at, updated_at
amount_sats, checkout_url, created_at, updated_at, policy_id
FROM invoices
WHERE status = 'pending' AND created_at >= ?
ORDER BY created_at ASC",
@@ -263,6 +318,7 @@ fn row_to_invoice(row: sqlx::sqlite::SqliteRow) -> Invoice {
checkout_url: row.get("checkout_url"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
policy_id: row.try_get("policy_id").ok().flatten(),
}
}
@@ -553,7 +609,8 @@ pub async fn log_validation(
const POLICY_COLS: &str = "id, product_id, name, slug, duration_seconds, grace_seconds,
tip_recipient, tip_pct_bps, tip_label,
max_machines, is_trial, price_sats_override,
entitlements_json, metadata_json, active, created_at, updated_at";
entitlements_json, metadata_json, active, public,
created_at, updated_at";
#[allow(clippy::too_many_arguments)]
pub async fn create_policy(
@@ -577,12 +634,13 @@ pub async fn create_policy(
let entitlements_json = serde_json::to_string(entitlements).unwrap_or_else(|_| "[]".into());
let metadata_json = serde_json::to_string(metadata).unwrap_or_else(|_| "{}".into());
let tip_pct = tip_pct_bps.clamp(0, 10_000);
// public defaults to 1 here; admin can flip via PATCH /v1/admin/policies/:id/public.
sqlx::query(
"INSERT INTO policies
(id, product_id, name, slug, duration_seconds, grace_seconds, max_machines,
is_trial, price_sats_override, entitlements_json, metadata_json, active,
is_trial, price_sats_override, entitlements_json, metadata_json, active, public,
tip_recipient, tip_pct_bps, tip_label, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(product_id)
@@ -650,6 +708,127 @@ pub async fn list_policies_by_product(
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`.
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
ORDER BY COALESCE(price_sats_override, 0) ASC, name ASC"
);
let rows = sqlx::query(&sql).bind(product_id).fetch_all(pool).await?;
Ok(rows.into_iter().map(row_to_policy).collect())
}
/// Patch mutable fields on a policy. Slug, product_id, and id are
/// intentionally not editable — they're identifiers that operators may
/// have hard-coded into integration docs or buy URLs. Tip-related fields
/// have their own admin endpoint (`set_policy_tip_config`) since they
/// have their own validation rules (basis points, paired recipient/pct).
#[allow(clippy::too_many_arguments)]
pub async fn update_policy(
pool: &SqlitePool,
id: &str,
name: Option<&str>,
duration_seconds: Option<i64>,
grace_seconds: Option<i64>,
max_machines: Option<i64>,
is_trial: Option<bool>,
price_sats_override: Option<Option<i64>>,
entitlements: Option<&[String]>,
metadata: Option<&serde_json::Value>,
) -> AppResult<Policy> {
let mut sets: Vec<&str> = Vec::new();
if name.is_some() {
sets.push("name = ?");
}
if duration_seconds.is_some() {
sets.push("duration_seconds = ?");
}
if grace_seconds.is_some() {
sets.push("grace_seconds = ?");
}
if max_machines.is_some() {
sets.push("max_machines = ?");
}
if is_trial.is_some() {
sets.push("is_trial = ?");
}
if price_sats_override.is_some() {
sets.push("price_sats_override = ?");
}
if entitlements.is_some() {
sets.push("entitlements_json = ?");
}
if metadata.is_some() {
sets.push("metadata_json = ?");
}
if sets.is_empty() {
return get_policy_by_id(pool, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("policy {id}")));
}
sets.push("updated_at = ?");
let now = Utc::now().to_rfc3339();
let sql = format!("UPDATE policies SET {} WHERE id = ?", sets.join(", "));
let mut q = sqlx::query(&sql);
if let Some(v) = name {
q = q.bind(v);
}
if let Some(v) = duration_seconds {
q = q.bind(v);
}
if let Some(v) = grace_seconds {
q = q.bind(v);
}
if let Some(v) = max_machines {
q = q.bind(v);
}
if let Some(v) = is_trial {
q = q.bind(v as i64);
}
if let Some(opt_p) = price_sats_override {
q = q.bind(opt_p);
}
let ent_json;
if let Some(ents) = entitlements {
ent_json = serde_json::to_string(ents).unwrap_or_else(|_| "[]".into());
q = q.bind(&ent_json);
}
let meta_json;
if let Some(m) = metadata {
meta_json = serde_json::to_string(m).unwrap_or_else(|_| "{}".into());
q = q.bind(&meta_json);
}
q = q.bind(&now).bind(id);
let rows = q.execute(pool).await?.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("policy {id}")));
}
get_policy_by_id(pool, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("policy {id}")))
}
pub async fn set_policy_public(pool: &SqlitePool, id: &str, public: bool) -> AppResult<()> {
let now = Utc::now().to_rfc3339();
let rows = sqlx::query("UPDATE policies SET public = ?, updated_at = ? WHERE id = ?")
.bind(public as i64)
.bind(&now)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("policy {id}")));
}
Ok(())
}
pub async fn set_policy_active(pool: &SqlitePool, id: &str, active: bool) -> AppResult<()> {
let now = Utc::now().to_rfc3339();
let rows = sqlx::query("UPDATE policies SET active = ?, updated_at = ? WHERE id = ?")
@@ -673,6 +852,7 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default();
let active_int: i64 = row.get("active");
let is_trial_int: i64 = row.get("is_trial");
let public_int: i64 = row.try_get("public").unwrap_or(1);
Policy {
id: row.get("id"),
product_id: row.get("product_id"),
@@ -686,6 +866,7 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
entitlements,
metadata,
active: active_int != 0,
public: public_int != 0,
tip_recipient: row.get("tip_recipient"),
tip_pct_bps: row.get("tip_pct_bps"),
tip_label: row.get("tip_label"),
@@ -1348,9 +1529,12 @@ pub async fn create_discount_code(
referrer_label: Option<&str>,
description: &str,
) -> AppResult<DiscountCode> {
if !matches!(kind, "percent" | "fixed_sats" | "free_license") {
if !matches!(
kind,
"percent" | "fixed_sats" | "set_price" | "free_license"
) {
return Err(AppError::BadRequest(format!(
"discount kind must be 'percent', 'fixed_sats', or 'free_license', got '{kind}'"
"discount kind must be 'percent', 'fixed_sats', 'set_price', or 'free_license', got '{kind}'"
)));
}
if amount < 0 {
@@ -1366,6 +1550,11 @@ pub async fn create_discount_code(
"fixed_sats amount must be > 0".into(),
));
}
if kind == "set_price" && amount <= 0 {
return Err(AppError::BadRequest(
"set_price amount (the buyer's flat-price target, in sats) must be > 0".into(),
));
}
// free_license codes ignore `amount`; we force it to 0 on insert below.
if let Some(m) = max_uses {
if m <= 0 {
@@ -1489,6 +1678,117 @@ pub async fn set_discount_code_active(
Ok(())
}
/// Patch mutable fields on a discount code. Mutable fields are the ones
/// that don't change behavior in confusing ways for codes already in
/// circulation: `amount`, `max_uses`, `expires_at`, `description`,
/// `referrer_label`. The code string itself, kind, and product/policy
/// scope are intentionally NOT editable — changing those would silently
/// invalidate links that are already out in the wild. Operators should
/// disable + create a new code instead. Each `Option<T>` parameter is
/// `Some(value_or_clear)` to update, `None` to leave alone; for fields
/// that can be NULL'd, callers pass `Some(None)` to clear.
#[allow(clippy::too_many_arguments)]
pub async fn update_discount_code(
pool: &SqlitePool,
id: &str,
amount: Option<i64>,
max_uses: Option<Option<i64>>,
expires_at: Option<Option<&str>>,
description: Option<&str>,
referrer_label: Option<Option<&str>>,
) -> AppResult<DiscountCode> {
// Re-fetch to validate amount against the existing kind.
let existing = get_discount_code_by_id(pool, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("discount code {id}")))?;
if let Some(a) = amount {
if a < 0 {
return Err(AppError::BadRequest("amount must be >= 0".into()));
}
if existing.kind == "percent" && a > 10_000 {
return Err(AppError::BadRequest(
"percent amount must be in basis points (0..=10000); 10000 = 100%".into(),
));
}
if existing.kind == "fixed_sats" && a == 0 {
return Err(AppError::BadRequest(
"fixed_sats amount must be > 0".into(),
));
}
if existing.kind == "set_price" && a <= 0 {
return Err(AppError::BadRequest(
"set_price amount (the buyer's flat-price target, in sats) must be > 0".into(),
));
}
if existing.kind == "free_license" && a != 0 {
return Err(AppError::BadRequest(
"free_license codes have no amount; pass 0 or leave unchanged".into(),
));
}
}
if let Some(Some(m)) = max_uses {
if m <= 0 {
return Err(AppError::BadRequest(
"max_uses must be > 0 (or pass null to clear it for unlimited)".into(),
));
}
if m < existing.used_count {
return Err(AppError::BadRequest(format!(
"max_uses ({m}) cannot be lower than the current used_count ({})",
existing.used_count
)));
}
}
let mut sets: Vec<&str> = Vec::new();
if amount.is_some() {
sets.push("amount = ?");
}
if max_uses.is_some() {
sets.push("max_uses = ?");
}
if expires_at.is_some() {
sets.push("expires_at = ?");
}
if description.is_some() {
sets.push("description = ?");
}
if referrer_label.is_some() {
sets.push("referrer_label = ?");
}
if sets.is_empty() {
return Ok(existing);
}
sets.push("updated_at = ?");
let sql = format!(
"UPDATE discount_codes SET {} WHERE id = ?",
sets.join(", ")
);
let now = Utc::now().to_rfc3339();
let mut q = sqlx::query(&sql);
if let Some(a) = amount {
q = q.bind(a);
}
if let Some(opt_m) = max_uses {
q = q.bind(opt_m);
}
if let Some(opt_e) = expires_at {
q = q.bind(opt_e);
}
if let Some(d) = description {
q = q.bind(d);
}
if let Some(opt_r) = referrer_label {
q = q.bind(opt_r);
}
q = q.bind(&now).bind(id);
q.execute(pool).await?;
get_discount_code_by_id(pool, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("discount code {id}")))
}
/// Phase 1 of redemption: atomically increment `used_count` on the
/// discount code, gated on active/not-expired/has-uses-remaining. Returns
/// `BadRequest` if any of those checks fails. The caller MUST follow up
@@ -1697,6 +1997,85 @@ pub async fn settings_get(pool: &SqlitePool, key: &str) -> AppResult<Option<Stri
Ok(row.and_then(|r| r.get::<Option<String>, _>("value")))
}
// ---------- Web UI sessions ----------
/// Create a new session row. Token is the random URL-safe base64 string
/// (callers generate it with `crate::api::auth::new_session_token`).
pub async fn create_session(
pool: &SqlitePool,
token: &str,
created_at: &str,
expires_at: &str,
ip: Option<&str>,
user_agent: Option<&str>,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO sessions (token, created_at, expires_at, last_seen_at, ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(token)
.bind(created_at)
.bind(expires_at)
.bind(created_at) // last_seen_at = created_at on insert
.bind(ip)
.bind(user_agent)
.execute(pool)
.await?;
Ok(())
}
/// Returns true if the session exists and hasn't expired. Side-effect:
/// bumps `last_seen_at` so an active session stays alive (sliding window).
pub async fn is_session_valid(pool: &SqlitePool, token: &str) -> AppResult<bool> {
let row = sqlx::query_as::<_, (String, String)>(
"SELECT token, expires_at FROM sessions WHERE token = ?",
)
.bind(token)
.fetch_optional(pool)
.await?;
let Some((_, expires_at)) = row else { return Ok(false) };
let exp = match chrono::DateTime::parse_from_rfc3339(&expires_at) {
Ok(t) => t.with_timezone(&Utc),
Err(_) => return Ok(false),
};
if exp < Utc::now() {
return Ok(false);
}
let now = Utc::now().to_rfc3339();
let _ = sqlx::query("UPDATE sessions SET last_seen_at = ? WHERE token = ?")
.bind(&now)
.bind(token)
.execute(pool)
.await;
Ok(true)
}
/// Hard-delete a single session row. Idempotent.
pub async fn delete_session(pool: &SqlitePool, token: &str) -> AppResult<()> {
sqlx::query("DELETE FROM sessions WHERE token = ?")
.bind(token)
.execute(pool)
.await?;
Ok(())
}
/// Wipe every session row — used on password rotation.
pub async fn delete_all_sessions(pool: &SqlitePool) -> AppResult<()> {
sqlx::query("DELETE FROM sessions").execute(pool).await?;
Ok(())
}
/// Background cleanup: drop sessions whose `expires_at` is in the past.
/// Returns the number of rows removed (for logging).
pub async fn reap_expired_sessions(pool: &SqlitePool) -> AppResult<u64> {
let now = Utc::now().to_rfc3339();
let res = sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
.bind(&now)
.execute(pool)
.await?;
Ok(res.rows_affected())
}
/// Upsert a key into the runtime settings table. Pass `None` to clear it.
pub async fn settings_set(pool: &SqlitePool, key: &str, value: Option<&str>) -> AppResult<()> {
let now = Utc::now().to_rfc3339();
+33 -2
View File
@@ -35,6 +35,23 @@ pub enum AppError {
#[error("BTCPay not configured: connect via the StartOS dashboard first")]
BtcpayNotConfigured,
#[error("too many requests: {0}")]
TooManyRequests(String),
#[error("service unavailable: {0}")]
ServiceUnavailable(String),
/// 402 Payment Required — used for tier-gate enforcement when the
/// operator's Keysat self-license doesn't include the entitlement
/// or capacity needed for the requested operation. The fields are
/// surfaced in the JSON body so the admin SPA can render an upgrade
/// CTA without parsing the message string.
#[error("payment required: {message}")]
PaymentRequired {
message: String,
upgrade_url: String,
},
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
@@ -53,17 +70,31 @@ impl IntoResponse for AppError {
AppError::LicenseInvalid(_) => (StatusCode::OK, "invalid"),
AppError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream_error"),
AppError::BtcpayNotConfigured => (StatusCode::SERVICE_UNAVAILABLE, "btcpay_not_configured"),
AppError::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"),
AppError::ServiceUnavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable"),
AppError::PaymentRequired { .. } => (StatusCode::PAYMENT_REQUIRED, "tier_cap"),
AppError::Database(_) | AppError::Internal(_) => {
tracing::error!(error = %self, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
}
};
let body = Json(json!({
// Tier-cap 402 carries a structured upgrade_url alongside the
// message so the SPA can render an upgrade-CTA button without
// having to parse a URL out of the human-facing message.
let body = match &self {
AppError::PaymentRequired { message, upgrade_url } => Json(json!({
"ok": false,
"error": code,
"message": message,
"upgrade_url": upgrade_url,
})),
_ => Json(json!({
"ok": false,
"error": code,
"message": self.to_string(),
}));
})),
};
(status, body).into_response()
}
+17
View File
@@ -98,6 +98,23 @@ async fn main() -> anyhow::Result<()> {
reconcile::spawn(state.clone());
webhooks::spawn_delivery_worker(state.clone());
// Hourly session reaper — drops sessions whose expires_at < now.
{
let pool = state.db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
match db::repo::reap_expired_sessions(&pool).await {
Ok(n) if n > 0 => tracing::info!("reaped {n} expired session(s)"),
Ok(_) => {}
Err(e) => tracing::warn!("session reaper: {e}"),
}
}
});
}
let app = api::router(state).layer(TraceLayer::new_for_http());
// --- serve ---
+14
View File
@@ -57,6 +57,11 @@ pub struct Invoice {
pub checkout_url: String,
pub created_at: String,
pub updated_at: String,
/// Policy chosen by the buyer at purchase time. NULL on pre-:27 invoices,
/// in which case `issue_license_for_invoice` falls back to picking the
/// product's default policy. Migration 0007 adds the column.
#[serde(default)]
pub policy_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -135,10 +140,19 @@ pub struct Policy {
pub tip_pct_bps: i64,
/// Free-form label for the tip recipient — surfaced in the audit log.
pub tip_label: Option<String>,
/// When true, the policy is rendered on /buy/<product-slug> as a
/// selectable tier card. Operators can mark "Comp / press" or
/// "Internal team seat" policies as private to keep them off the
/// public buy page while still issuing them via admin tooling.
/// Defaults to true; migration 0007 adds this column.
#[serde(default = "default_true")]
pub public: bool,
pub created_at: String,
pub updated_at: String,
}
fn default_true() -> bool { true }
/// A machine activated under a license. One row per active install.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Machine {
File diff suppressed because it is too large Load Diff
+2
View File
@@ -22,11 +22,13 @@ import { sdk } from '../sdk'
import { activateLicense, showLicenseStatus } from './activateLicense'
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
import { setOperatorName } from './setOperatorName'
import { setWebUiPassword } from './setWebUiPassword'
import { showCredentials } from './showCredentials'
export const actions = sdk.Actions.of()
// General
.addAction(setOperatorName)
.addAction(setWebUiPassword)
// BTCPay setup
.addAction(configureBtcpay)
.addAction(btcpayStatus)
+93
View File
@@ -0,0 +1,93 @@
// Action: set or rotate the web UI password.
//
// Until v0.1.0:28 the only way to sign into the admin web UI was to paste
// the admin API key into a localStorage-backed login form. This action
// lets the operator set a real password instead — argon2id-hashed and
// stored in the daemon's settings table. After it's set, the SPA login
// page shows a password field; existing API key continues to work for
// automation.
//
// Rotating the password invalidates all existing sessions (forced
// re-login). Minimum length: 12 characters, enforced server-side.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const { InputSpec, Value } = sdk
const input = InputSpec.of({
password: Value.text({
name: 'New password',
description:
'Minimum 12 characters. This is the password you will use to ' +
'sign into the admin web UI at /admin/. Setting (or rotating) ' +
'this invalidates any active web sessions — you will need to ' +
'sign in again with the new password.',
required: true,
masked: true,
minLength: 12,
default: null,
placeholder: '••••••••••••',
}),
confirm: Value.text({
name: 'Confirm password',
description: 'Re-type the password exactly to catch typos.',
required: true,
masked: true,
default: null,
placeholder: '••••••••••••',
}),
})
export const setWebUiPassword = sdk.Action.withInput(
'set-web-ui-password',
async () => ({
name: 'Set web UI password',
description:
'Set or change the password used to sign into the admin web UI. ' +
'Replaces the API-key paste step on the login page.',
warning:
'Rotating the password signs out every active web session and ' +
'forces a fresh login.',
allowedStatuses: 'only-running',
group: 'General',
visibility: 'enabled',
}),
input,
// No prefill — passwords are sensitive.
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
if (formInput.password !== formInput.confirm) {
throw new Error('Passwords do not match. Re-type carefully.')
}
if (formInput.password.length < 12) {
throw new Error('Password must be at least 12 characters.')
}
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/web-password',
{ method: 'POST', body: JSON.stringify({ password: formInput.password }) },
)
if (!resp.ok) {
throw new Error(
`Password update failed: HTTP ${resp.status}${await resp.text()}`,
)
}
return {
version: '1',
title: 'Web UI password set',
message:
'Password saved. Next time you visit the admin web UI, sign in ' +
'with the new password. Any existing browser session was invalidated; ' +
'all signed-in tabs need to log in again. The admin API key continues ' +
'to work for automation.',
result: null,
}
},
)
+1 -1
View File
@@ -13,7 +13,7 @@ import { short, long } from './i18n'
export const manifest = setupManifest({
id: 'keysat',
title: 'Keysat',
title: 'Keysat Licensing',
license: 'LicenseRef-Proprietary',
packageRepo: 'https://github.com/keysat-xyz/keysat-startos',
upstreamRepo: 'https://github.com/keysat-xyz/keysat',
+206 -1
View File
@@ -9,8 +9,213 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v0_1_0 = VersionInfo.of({
version: '0.1.0:24',
version: '0.1.0:40',
releaseNotes: [
`Alpha-iteration revision 40 of v0.1.0 — Hotfix: migration 0009 in v0.1.0:39 was malformed and put the daemon in a startup-restart loop. This release is the corrected migration; install it to recover.`,
``,
`**The bug.** Migration 0009 in :39 did its own \`BEGIN TRANSACTION\` / \`COMMIT\` and a \`PRAGMA foreign_keys = OFF\`. sqlx-migrate already wraps each .sql file in a transaction, and SQLite doesn't allow nested transactions — so the inner BEGIN failed, sqlx rolled back the wrapping txn, the migration was never recorded as applied, and the daemon panicked on every boot. StartOS showed "Running" but the API was unreachable because the process kept exiting before binding port 8080.`,
``,
`**The fix.** Removed the redundant BEGIN/COMMIT (sqlx provides them). Replaced \`PRAGMA foreign_keys = OFF\` with \`PRAGMA defer_foreign_keys = 1\`, which is transaction-local — it postpones FK checks until COMMIT time without trying to disable them globally inside a transaction. Since the rebuild preserves all row IDs, the FK check at COMMIT passes cleanly.`,
``,
`**No data loss in :39's failure.** sqlx rolled back the wrapping transaction, so the discount_codes table is unchanged from :38. The new migration 0009 will apply cleanly to your existing database when you install :40.`,
``,
`Alpha-iteration revision 39 of v0.1.0 — Edit-able products and policies, license counts in the admin tables, and a critical migration fix.`,
``,
`**Migration 0009 — fix the discount-codes CHECK constraint to allow 'set_price'.** The 'set_price' kind was added at the daemon layer in v0.1.0:26, but the original 0004 migration's CHECK constraint only listed 'percent' / 'fixed_sats' / 'free_license'. SQLite was rejecting any insert with 'set_price' as "CHECK constraint failed". Migration 0009 rebuilds the discount_codes table with the four-kind CHECK; SQLite doesn't support ALTER TABLE DROP CONSTRAINT so the rebuild is the right path. All existing rows are preserved.`,
``,
`**Edit products from the admin UI.** New endpoint PATCH /v1/admin/products/:id and a corresponding Edit modal on the Products tab. Mutable: name, description, price_sats. Slug is intentionally not editable (it's part of the public buy URL — changing it breaks bookmarks; operators should disable + create a new product to rename).`,
``,
`**Edit policies from the admin UI.** New endpoint PATCH /v1/admin/policies/:id and a corresponding Edit modal on the Policies tab. Mutable: name, tier description, price override, duration (preset or custom), grace period, max devices, trial flag, entitlements, "Most popular" highlight. Slug + product + tip config are not editable here (tip has its own dedicated PATCH with separate validation rules).`,
``,
`**License counts in the Products and Policies tables.** New endpoint GET /v1/admin/licenses/counts returns license counts grouped by product_id and policy_id. The Products tab gains a Licenses column; the Policies tab gains the same. One COUNT-by-group query per fetch; cheap.`,
``,
`**On the discount-code-per-product question:** the existing create-code form already has a "Restrict to product slug (optional)" field — set it to scope the code to one product. Codes left unrestricted (slug field blank) work for any product. No change needed.`,
``,
`Alpha-iteration revision 38 of v0.1.0 — Force-delete for products and policies, exposed in the admin UI. Lets operators wipe test data they've accumulated against a product (or policy) — including issued licenses and invoices — without dropping to the SQLite shell.`,
``,
`**Backend.** \`DELETE /v1/admin/products/:id\` and \`DELETE /v1/admin/policies/:id\` now accept an optional \`?force=true\` query param.`,
` - Without \`force\` (the safe default): refuses with 409 if any invoice or license references the product / policy. Same behavior as :33.`,
` - With \`?force=true\`: cascades through the dependency tree in a single transaction — machines → discount redemptions → licenses → invoices → policies / codes → product. Audit log records the cascade counts (cascaded_licenses, cascaded_invoices, etc.) for forensic backtracking. Audit action is "product.force_delete" / "policy.force_delete" so the destructive variant is searchable.`,
``,
`**Admin UI.** Both Delete buttons now flow through a smart \`safeOrForceDelete\` helper:`,
` - First click → tries the safe DELETE. If it succeeds, done.`,
` - If the server returns 409 → opens a force-delete modal showing the original conflict message + a "type the slug to confirm" GitHub-style input. The "Force delete (irreversible)" button stays disabled until the typed slug matches exactly.`,
` - On confirm → POSTs with \`?force=true\`, then shows a brief toast summarizing what got cascaded ("product 'foo' force-deleted — also wiped: 3 license(s), 5 invoice(s)").`,
``,
`Type-the-slug confirmation prevents accidental nukes — single-click can never wipe customer history. Designed for the operator-tinkering use case where you've issued test licenses against a product you want to delete cleanly.`,
``,
`**Why expose this in the UI.** The same effect was previously achievable only via direct SQL inside the container (sqlite3 /data/keysat.db). Operators tinkering with new products + policies hit this constantly during pre-launch testing, and dropping into the container for what's logically a UI operation is hostile. This puts the same capability behind a sensible-friction confirmation dialog, with proper audit logging that the SQL path skipped.`,
``,
`Alpha-iteration revision 37 of v0.1.0 — Critical hotfix: the tier picker on /buy/<slug> was completely broken on every page load (TDZ error in the on-load \`selectTier(selectedPolicy)\` sync I added in :32). Symptom: clicking a tier card didn't update the price card; clicking Pay with Bitcoin caused a page reload instead of submitting; submit handler never attached because the IIFE threw before it ran.`,
``,
`Root cause: \`let appliedCode\` was declared after the on-load selectTier call, but selectTier's body reads \`appliedCode\` (\`if (appliedCode) { ... }\`). The on-load call hit appliedCode in its temporal-dead-zone, ReferenceError thrown, IIFE aborted, every subsequent handler attachment skipped.`,
``,
`Fix: hoisted \`let appliedCode = null;\` to the top of the IIFE alongside the other state variables. One line move; no behavior change beyond "the tier picker actually works again."`,
``,
`Why this slipped through: the on-load selectTier call shipped in :32 alongside a bunch of other changes; the bug only manifests when a tier is server-pre-selected, which is the common case but wasn't the path I exercised in the post-:32 sanity walkthrough.`,
``,
`Alpha-iteration revision 36 of v0.1.0 — Three test-driven UX fixes from the v0.1.0:35 dogfood walk-through.`,
``,
`**Tier-cap 402 is now an actionable modal**, not a text alert with the URL pasted into the message string. Server-side: AppError::PaymentRequired now carries \`{message, upgrade_url}\` separately and emits a structured 402 body. Client-side: api() throws errors annotated with \`status\` and \`body\`; a new handleTierCap() helper renders a cream/gold modal with a real "Get Pro license →" button when the response is a 402 with an upgrade_url. Falls back gracefully to the existing inline status pill / alert for non-tier-cap errors.`,
``,
`**Product delete is less restrictive.** Pre-:36 it refused if any policy referenced the product. That blocked the legitimate "I made setup mistakes and want to clean up" flow. Now: refuses only if INVOICES or LICENSES exist (real customer history). Policies and product-scoped discount codes get cascade-deleted in a single transaction since they're templates with no audit-trail value on their own. Audit log records the cascade counts for traceability. The policy-delete safety pattern is unchanged (still refuses on invoices+licenses).`,
``,
`**Manual license issuance from the admin UI.** The Licenses tab gains a "Manually issue a license" disclosure with a clean form: Product + Policy dropdowns (policies auto-filter when product changes), optional buyer email, optional internal note. Submit POSTs to the existing /v1/admin/licenses endpoint and shows the resulting signed key in a "Save it now" modal with a Copy button. Useful for self-issuing a Pro license to dogfood, comp licenses for press / partners / friends, or paper-licensing flows that don't go through BTCPay.`,
``,
`Alpha-iteration revision 35 of v0.1.0 — Fix the "Embed your public key" tip card actually showing the key. The :32 endpoint returns \`public_key_pem\` but the SPA was reading a stale \`public_key_b64\` field name from the very first scaffold of the admin UI. Renamed reading-side to accept either shape. Preview now strips the PEM BEGIN/END headers so the 12+12-char preview shows the actual key bytes instead of "-----BEGIN PUB…BLIC KEY-----". Copy button still copies the full PEM verbatim, ready to paste into source.`,
``,
`Alpha-iteration revision 34 of v0.1.0 — Display name on the StartOS services list reads "Keysat Licensing" instead of just "Keysat". Distinguishes the package from anything else in the future Keysat product family. Package id remains \`keysat\` (no migration); only the human-facing title changed.`,
``,
`Also bundled in this build (carried over from :33's Dockerfile change which was applied after some folks had already built :33): \`sqlite3\` is now in the runtime container, so \`start-cli package attach keysat\` operators have an SQL shell on hand for occasional admin tasks.`,
``,
`Alpha-iteration revision 33 of v0.1.0 — Keysat dogfoods its own tier model. Creator / Pro / Patron are now real, with caps enforced server-side and a persistent upgrade banner in the admin sidebar.`,
``,
`**Tier model.** Three tiers, all derivable from entitlements on the daemon's self-license:`,
` - **Creator** — entitlements: ["self_host"]. Caps: 5 products, 5 policies/product, 5 active discount codes. BTCPay only, one-time purchases. Sold for ~21,000 sats; also distributable via free codes for hobbyists.`,
` - **Pro** — entitlements: ["self_host", "unlimited_products", "unlimited_policies", "unlimited_codes", "recurring_billing", "card_payments", "team_seats"]. Unlimited everything, unlocks Zaprite + recurring billing when those ship in v0.3.`,
` - **Patron** — Pro + ["patron"]. Same feature surface, plus a Patron badge. Voluntary upsell for funding development.`,
` - **Unlicensed** — same caps as Creator. Operators can install and use Keysat without paying us anything; the upgrade pull is organic when they need more capacity.`,
``,
`**Server-side caps enforced** in /v1/admin/products, /v1/admin/policies, /v1/admin/discount-codes create handlers. Returning HTTP 402 Payment Required with a clear message and an \`upgrade_url\` pointing at the master Keysat's buy page. Caps fire at create-time only — operators above the cap aren't retroactively kicked off.`,
``,
`**Persistent upgrade banner** in the admin sidebar. Always visible (regardless of whether you're at a cap). Shows current tier label, contextual message, and the next-tier CTA. Patron operators see "Thank you for funding development" instead of an upgrade pitch. Pro operators see the Patron CTA. Creator/Unlicensed see the Pro CTA with a usage line ("Currently using 3/5 products"). Backed by a new admin endpoint GET /v1/admin/tier returning {tier, entitlements, usage, caps, upgrade_url}.`,
``,
`**Delete buttons on Products and Policies.** Same safety pattern as the existing discount-code Delete: hard-delete refused with 409 if any references exist (policies/invoices/licenses for a product; invoices/licenses for a policy). Operator should disable / hide instead in that case. Audit-logged.`,
``,
`**Pricing page** at keysat-docs/pricing.html — Creator / Pro / Patron tier comparison cards, what the caps count, how to switch tiers, what's coming in v0.3.`,
``,
`**Test-data wipe doc** at RESET_TEST_DATA.md (root of the workspace) — one-liner SQL for clearing pre-launch test data on a master Keysat that's accumulated stale products/policies/licenses during testing.`,
``,
`**No DB schema changes** — caps are enforced via existing entitlements field on the License/Policy models. Migrations 00010008 unchanged.`,
``,
`Alpha-iteration revision 32 of v0.1.0 — Three real bugs the first end-to-end tier test surfaced.`,
``,
`**Free-tier checkout no longer goes through BTCPay.** Before: clicking Pay on the Free tier created a 0-sat (well, 1-sat — BTCPay floor) invoice and dropped the buyer on a confusing "amount paid: 0 BTC" receipt. They had to click "Return to Keysat" to actually get the license. Now: when /v1/purchase computes a final price of 0 (free tier price_override=0, OR a paid tier with 100%-off), the daemon synthesizes a settled invoice locally, issues the license inline, and returns the signed key in the response body. The buy page detects the inline-license response and renders the license card directly — no BTCPay roundtrip, no fake receipt, no extra clicks. The button label also updates correctly: clicking a Free tier on the picker flips the CTA to "Redeem license" and the price card to "FREE", so the buyer sees the right state before submitting.`,
``,
`**Public key now resolves at the documented endpoint.** The admin Overview's "Embed your public key" tip was showing "unavailable" because the SPA fetches /v1/issuer/public-key but no handler was wired. Added the GET endpoint (no auth required — public keys are by definition public). The same endpoint will be useful to SDK consumers fetching the operator's signing key dynamically. Returns \`{public_key_pem, key_algorithm, key_format_version}\`.`,
``,
`**Admin Licenses table now shows entitlements + policy.** Two new columns:`,
` - **Policy**: the policy slug under which the license was issued (e.g. "free", "pro", "patron"). Hover for the policy's display name.`,
` - **Entitlements**: small mono-style chips for each entitlement on the license (e.g. \`self_host\`, \`recurring_billing\`).`,
`The product column now shows the product slug instead of a UUID prefix. Backed by a server-side enrichment in /v1/admin/licenses/search that joins to policies + products in two small queries — same response shape, just with extra fields.`,
``,
`**On the BTCPay "Return to Keysat" button label** (you asked): yes, it's customizable, but from BTCPay's side, not Keysat's. In your BTCPay → Store Settings → Checkout Appearance, there's a "Custom checkout CSS / store branding" area where you can tweak the button text and colors. The label is rendered by BTCPay's invoice frontend, not by Keysat. Recommended: change "Return to Keysat" to "View license" or "Get license" — same redirect, friendlier copy.`,
``,
`Alpha-iteration revision 31 of v0.1.0 — Tip-suggestion copy uses keysat@primal.net directly instead of the brand-aliased tip@keysat.xyz. Avoids the LNURL-pay static-proxy setup until we want a branded address.`,
``,
`One-line text change in the policy create form's tip-recipient hint and example. No behavior change otherwise — operators can paste any Lightning Address.`,
``,
`Alpha-iteration revision 30 of v0.1.0 — Price-per-tier on the policy form. Operators can set Free=0, Pro=250000, Patron=500000 etc. directly without curl gymnastics.`,
``,
`New "Price (sats)" field on the policy create form. Pre-fills with the chosen product's base price (the dropdown shows each product's price inline so the operator doesn't have to remember). Picking a different product re-prefills, unless the operator has already edited the value away from the prefill — in which case their edit is preserved (no clobbering).`,
``,
`Wire path: form → JSON body's \`price_sats_override\` → existing /v1/admin/policies POST → existing \`price_sats_override\` column on policies. The buy-page tier picker reads this for its per-card pricing. \`price_sats_override = 0\` works as a free tier (the buyer is never charged).`,
``,
`No backend changes — the API has accepted \`price_sats_override\` from day one; we just weren't exposing it on the form.`,
``,
`Alpha-iteration revision 29 of v0.1.0 — policy-create form rebuilt to remove the JSON-foot-guns. No more "do I type the brackets?" moments.`,
``,
`**Form-level changes** to the Policies → Create-a-new-policy disclosure in the admin SPA:`,
` - **Duration** is now a preset dropdown (Perpetual / 7d / 30d / 90d / 6mo / 1y / 2y / Custom) instead of a raw seconds input. Custom drops back to a seconds field for power users; otherwise the operator never sees the number 31536000 again.`,
` - **Grace period** moved to days (was seconds). The form does the *86400 conversion silently.`,
` - **Tier description** is now a dedicated text field. It writes to \`metadata.description\` under the hood, which the buy-page tier picker reads. Operators never see the metadata JSON.`,
` - **Mark as "Most popular"** is now a checkbox. Writes to \`metadata.highlight\` so the corresponding tier card gets a gold "Most popular" pill on /buy/<product>.`,
` - **Entitlements** is now a textarea (was a single-line input). Operators can write one entitlement per line OR comma-separated. The form is also defensive: it strips any \`[\`, \`]\`, \`"\`, \`'\` characters in case someone pastes a JSON-style array. No more "did I need quotes?".`,
` - **Max machines** relabeled "Max devices (0 = unlimited)" — clearer than "machines".`,
` - Inline help text on every field.`,
``,
`OpenSats Lightning address corrected to \`opensats@npub.cash\` (was the older nostrplebs.com address) in the tip-recipient suggestions.`,
``,
`**No backend changes.** The policies API has always accepted a \`metadata\` JSON object; we just weren't exposing the right fields in the form. Existing policies continue to work; their metadata is preserved on read/write.`,
``,
`Why this matters: the buy-page tier picker (shipped in :27) needs descriptions and highlighting to look right. Until :29 the only way to set those was to know the metadata schema and craft a JSON object. Now there's a checkbox and a text field. Closer to the bar where a non-developer operator can ship a paid product without reading source.`,
``,
`Alpha-iteration revision 28 of v0.1.0 — password-based admin auth, BTCPay revenue stats on the Overview, and a positioning update on the Keysat website.`,
``,
`**Password-based admin web UI (replaces API-key paste).** New StartOS action **General → Set web UI password** lets the operator define an argon2id-hashed password (12-char minimum). After setting it, the admin login page shows a password field instead of asking for the API key. Sign-in mints a 24-hour HttpOnly+Secure+SameSite=Strict session cookie; sliding renewal on every authenticated request. The API key continues to work for automation and is still the fallback for the very first login (before a password has been set). Brute-force protection: per-IP token bucket on /admin/login (5-attempt burst + 1 token per 3 minutes). Rotating the password invalidates all active sessions.`,
``,
`Implementation: cookie sessions ride on top of the existing API-key require_admin guard via a small axum middleware (\`session_to_bearer\`) that injects the API key as Authorization when a valid cookie is present. Every existing admin handler keeps working unchanged. Audit-log limitation: cookie-authenticated calls show the API key's sha256 as the actor. IP / user-agent on the same row distinguish sessions in practice; per-session actor identity is a v0.2 follow-up.`,
``,
`**Migrations:** \`0008_web_sessions.sql\` adds a \`sessions\` table (additive). \`web_ui_password_hash\` lives in the existing settings table.`,
``,
`**New crate:** \`argon2 = "0.5"\` (PHC-recommended pure-Rust hashing). Adds ~30 KB to the binary.`,
``,
`**New endpoints:**`,
` - POST /admin/login (public; password → cookie)`,
` - POST /admin/logout (clears cookie + deletes session row)`,
` - GET /admin/login/status (public; \`{has_password, logged_in}\`)`,
` - POST /v1/admin/web-password (admin-only; sets/rotates the password hash)`,
``,
`**Background task:** hourly session reaper drops expired rows.`,
``,
`**BTCPay revenue stats on the Overview.** New stat card "Revenue (lifetime)" plus a four-cell breakdown (lifetime / 30d / 7d / 24h) below the existing stats row. Backed by a new endpoint \`GET /v1/admin/revenue/summary\` that sums \`amount_sats\` across settled invoices in the local DB. Free-license redemptions have amount=0 and don't contribute. We deliberately don't call the BTCPay API for this — every invoice we created is in our DB with status + amount, so a local SUM is faster and works even when BTCPay is down. (If we ever want refunds / fees / Lightning-vs-onchain breakdown, that's when we'd add a BTCPay roundtrip.)`,
``,
`**Landing-page positioning update** (separate repo, keysat-xyz-landing). New paragraph in the hero: "Keysat empowers independent software creators to monetize any software they choose to sell — fully open source, free/paid versions, or fully closed source. The licensing layer is agnostic to your decision." Distinguishes Keysat from open-source-only / SaaS-required licensing services and makes explicit that the operator owns the model decision.`,
``,
`Alpha-iteration revision 27 of v0.1.0 — tiered pricing on the buy page. Operators with multiple policies now see a side-by-side tier picker on /buy/<slug>; buyers pick a tier explicitly; the chosen policy round-trips through purchase → BTCPay invoice → settlement webhook → license issuance.`,
``,
`**The picker.** When a product has 2+ active+public policies, /buy/<slug> renders a card grid above the existing form: each card shows tier name, price, duration, key entitlements (as bullets), and a Select button. Selecting a tier highlights its card with a gold border + ring shadow, updates the price card below the picker with that tier's price, and flips the form's submit to use that tier. When the product has 0 or 1 public policies, the buy page renders exactly as before — no UX change.`,
``,
`**Pre-selection logic.** \`?policy=<slug>\` deep-link wins (lets operators link buyers to a specific tier from marketing). Otherwise, any policy with \`metadata.highlight = true\` is pre-selected (and gets a "Most popular" gold pill). Otherwise, the cheapest tier is selected. Buyers can always change their selection before submitting.`,
``,
`**Policy "public" flag.** New \`public\` boolean column on the \`policies\` table (migration 0007, additive, defaults to 1 for existing rows). Admin can hide internal policies — Comp / press / internal team-seat templates — from the public buy page while still issuing under them via /v1/admin/licenses. Admin SPA gains a Show/Hide button on each policy row and a "On buy page" column.`,
``,
`**New endpoint:** \`GET /v1/products/:slug/policies\` (public, no auth). Returns the product (slug, name, description, base price) and an array of active+public policies with their buyer-facing fields (slug, name, description, price_sats, duration_seconds, max_machines, is_trial, entitlements, highlighted). Internal fields (id, tip recipient, raw metadata) are deliberately omitted.`,
``,
`**New invoice column:** \`policy_id\` (nullable, FK to policies). Stores the buyer's chosen tier on the invoice so issue_license_for_invoice can use it as the template. Pre-:27 invoices fall back to the legacy "default policy" pick (slug='default' or first active) — no breaking change.`,
``,
`**Updated APIs:**`,
` - POST /v1/purchase: accepts optional \`policy_slug\`. When set, the policy's \`price_sats_override\` becomes the base price (if defined), and the policy is persisted on the invoice. Validates the chosen tier is active+public; admins issuing comps stay on /v1/admin/licenses.`,
` - POST /v1/redeem: accepts optional \`policy_slug\`, same semantics. Works for free-license codes that should be issued under a specific tier.`,
` - GET /v1/discount-codes/preview: accepts optional \`policy_slug\` query param. Discount math is computed against the chosen tier's effective price; codes restricted to a different policy return \`{valid: false, reason: "wrong_tier"}\`.`,
` - POST /v1/admin/policies/:id/public: new admin endpoint, audit-logged as policy.set_public. Toggles the public flag.`,
``,
`**Code-applied-to-policy enforcement.** Discount codes have an \`applies_to_policy_id\` column from migration 0004; pre-:27 it was informational only. Now it's enforced in /v1/purchase, /v1/redeem, and /v1/discount-codes/preview: a code restricted to a specific tier is rejected on any other tier with a clear error message.`,
``,
`**Buy-page tier metadata conventions** (no schema change required):`,
` - \`metadata.description\` (string): short blurb shown on the tier card. ~1 sentence works best.`,
` - \`metadata.highlight\` (bool): true → "Most popular" gold pill + pre-selection.`,
`Both are optional. Existing policies without these keys render fine, just plainer.`,
``,
`**Database changes:** migration 0007_tiered_pricing.sql, additive only:`,
` - ALTER TABLE policies ADD COLUMN public INTEGER NOT NULL DEFAULT 1;`,
` - ALTER TABLE invoices ADD COLUMN policy_id TEXT REFERENCES policies(id);`,
` - CREATE INDEX idx_policies_public ON policies(public);`,
``,
`**Net effect.** Operators can run a real free + paid tier model from a single buy URL. Keysat itself can list keysat-free / keysat-pro / keysat-patron tiers from one /buy/keysat URL once the corresponding policies are created on the master Keysat. Foundation for the v0.3 entitlement-gating of recurring billing + Zaprite (Pro-only features).`,
``,
`Alpha-iteration revision 26 of v0.1.0 — anonymous-friendly checkout, editable discount codes, and a new "set flat price" code kind.`,
``,
`**Anonymous-friendly checkout.** Email is now genuinely optional on /buy/<slug>. Reworded label to "Email (optional)" and the hint to: "Useful only if you want a buyer reference for lost-key recovery. Skip it to pay anonymously — your license key is shown directly on this site either way." The form no longer enforces \`required\`. The success card now displays the invoice id ("Reference for support: <id>") in place of the email-based reference, so an anonymous buyer who needs help still has a concrete reference to give the seller.`,
``,
`**Edit discount codes.** New endpoint: PATCH /v1/admin/discount-codes/:id. Mutable fields are amount, max_uses, expires_at, description, referrer_label. Code string, kind, and product/policy scope are deliberately NOT editable — changing those would silently invalidate links already in circulation; operators should disable + create a new code instead. max_uses cannot be set below the current used_count. Audit-logged as discount_code.update.`,
``,
`Admin UI gains an Edit button next to Disable/Delete on each code row. Clicking opens an inline edit panel above the table pre-populated with the code's current values; Save PATCHes and reloads, Cancel closes without changes.`,
``,
`**New "set flat price" discount kind.** \`kind: 'set_price'\` lets an operator say "with this code, the buyer pays exactly N sats" regardless of base price. Useful for promo flat-rates ("everyone gets it for 5000 sats this week") and for regional pricing. If amount is greater than or equal to base, the code provides no benefit (final price = base). Wired through the same three places the other kinds live: purchase math, free-redeem path doesn't apply (only \`free_license\` skips BTCPay), and the public preview endpoint shows "Flat price applied: N sats." Validation: amount must be > 0 at create time. Existing kinds (\`percent\`, \`fixed_sats\`, \`free_license\`) unchanged.`,
``,
`Admin UI: the create-code Kind dropdown gains "Set flat price (in sats)". The Amount field's hint updates live as the operator changes Kind, so the meaning of the number is always obvious. Codes table shows "5,000 sats flat" for set_price entries (vs "5,000 sats off" for fixed_sats).`,
``,
`No DB schema changes since :25 — all changes are at the application layer.`,
``,
`Alpha-iteration revision 25 of v0.1.0 — admin Licenses tab actually shows licenses, plus thank-you page hardening and honest copy.`,
``,
`**Bug fix (high-impact UX bug):** the admin Licenses tab was rendering an empty search box with no auto-load, so issued licenses appeared invisible until the operator typed something into the search field. The backend already returns the 100 most-recent licenses when called with no filters; the UI just never called it. Tab now auto-loads recent licenses on open, with a Clear button to reset back to recent after a search. Empty-state copy is friendlier ("No licenses issued yet — once a buyer purchases or redeems, they appear here") instead of misleading.`,
``,
`**New endpoint:** GET /v1/admin/licenses/summary — returns aggregate counts {total, active, suspended, revoked, last_24h, last_7d}. Wires the Overview dashboard's "Active licenses" stat card to a real value (it was silently 404ing and showing ""). Cheap query, runs on every dashboard load.`,
``,
`**Buy-form copy honest fix:** the email-field hint claimed "we'll send your license key here after payment confirms" — but Keysat doesn't have SMTP delivery yet. Reworded to "Used as your buyer reference for support and lost-key recovery. Your license key is shown directly on this site after payment." Same change on the inline success card and the /thank-you success card. Email sending is a v0.2 build; until then the buy-page promise matches what actually happens. Email is still captured and stored on the license — just not actively sent.`,
``,
`**Thank-you page hardening for buyers who click "Return to Keysat" early:** BTCPay's processing screen has a Return button. Clicking it before the invoice settles lands the buyer on /thank-you?invoice_id=… while the license isn't yet issued. The polling loop:`,
` - Was 12 minutes hard-cap with a fixed 3s cadence — tight against Bitcoin block-time variance, especially on slow blocks.`,
` - Now: adaptive cadence (3s for first 2 min, 10s for 210 min, 30s for 1030 min) and a 30-min hard deadline. Saves bandwidth on the buyer's phone without missing slow blocks.`,
` - Improved waiting copy that explains what's happening and tells the buyer the URL is bookmark-friendly so they can close the tab and come back.`,
` - The pending card now displays the invoice id ("Reference for support: <id>") so a buyer who hits the deadline has something concrete to send the seller.`,
``,
`Net effect: buyers can safely click Return-to-Keysat early without missing the license. Operators see issued licenses in the admin without having to search. Buy-page promises now match implementation.`,
``,
`No DB schema changes since :24.`,
``,
`Alpha-iteration revision 24 of v0.1.0 — Apply-discount button on the buy page + delete discount codes from the admin UI.`,
``,
`Buy page (/buy/<slug>) — buyers can now click an "Apply" button next to the discount code input to preview the discount before committing. The price card updates with strikethrough on the original price, the new price, and a green tag showing the percent or sats off. If the code is a free_license type, the primary CTA flips from "Pay with Bitcoin" to "Redeem license" and skips the BTCPay path entirely on submit. Validation happens against a new public endpoint GET /v1/discount-codes/preview which checks existence/active/expiry/product/exhaustion and computes the discounted price WITHOUT consuming a redemption slot. Editing the code after Apply resets the price card.`,