Buyer self-service recovery + db-info admin endpoint

Two operator-facing additions, both addressing risks we'd flagged
earlier in the v0.2 plan but hadn't shipped.

**POST /v1/recover (+ GET /recover HTML form).** Lets a buyer who
lost their license key re-derive it themselves by presenting their
invoice id + the email they paid with. Until now, the recovery
flow was "DM the operator with your invoice id and they re-send" —
operator-time scaling badly. With this, the buyer self-serves and
the operator never has to know.

The endpoint takes (invoice_id, email), case-insensitive on email.
Returns a generic 404 on any mismatch — does NOT distinguish
"invoice not found" from "wrong email" so an attacker can't
brute-force email addresses against a known invoice id. Per-IP
rate limited at 10 requests / minute. Audit-logged as
license.recovered with the email's SHA-256 hash so PII isn't
written to the log.

The HTML form at GET /recover is server-rendered, no JS framework,
no cookies — designed for a customer who's just had a catastrophic
failure of their primary computer and reached us from whatever
device they could find.

Test in tests/api.rs:recover_returns_license_key_for_matching_pair
exercises the happy path (case-insensitive email match), the
generic-404 paths (wrong email, missing invoice), the round-trip
(recovered key validates via /v1/validate), and the audit-log
write.

**GET /v1/admin/db-info.** Cheap insurance against the
catastrophic-loss risk: /data/keysat.db is a single SQLite file,
losing it invalidates every license ever issued. StartOS's backup
machinery handles snapshotting; this endpoint gives operators a
sanity-check surface they didn't have before:
  - DB file path + on-disk size
  - last-write timestamp (max across audit_log, invoices, licenses)
  - row counts for products, policies, licenses (total + active),
    invoices (total + settled), machines (active), discount codes,
    audit log entries

Doesn't report when StartOS last backed it up — the daemon has no
visibility into the host's snapshot subsystem. What it gives the
operator is a "I expected ~50 licenses and I see ~50 licenses; the
file is N MB; the last write was 6 hours ago" check.

Test count: 31 (was 30; +1 for the recover test).
This commit is contained in:
Grant
2026-05-08 11:05:10 -05:00
parent a7ea47fd63
commit f6ba1c160e
4 changed files with 517 additions and 0 deletions
+147
View File
@@ -991,3 +991,150 @@ async fn webhook_dlq_lists_failed_and_retry_requeues() {
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
/// Buyer self-service recovery: re-derive a lost license key from
/// (invoice_id, buyer_email). The most-common buyer support ticket
/// turned into a self-service flow.
///
/// Verifies:
/// - matching pair → 200 with a license_key that validates
/// - wrong email → 404 with the generic error message (does not
/// leak whether the invoice id existed)
/// - missing invoice → 404
/// - unsettled invoice → 404 (no license to recover)
/// - audit log row written on success
#[tokio::test]
async fn recover_returns_license_key_for_matching_pair() {
let (state, _tmp) = make_test_state().await;
// Seed a product, a settled invoice, and an active license.
let product = repo::create_product(
&state.db,
"rec-test",
"Recover Test",
"",
5_000,
&json!({}),
)
.await
.expect("create_product");
let invoice_id = Uuid::new_v4().to_string();
repo::create_invoice(
&state.db,
&invoice_id,
"btcpay-rec-1",
&product.id,
5_000,
"http://x/",
Some("Buyer@Example.COM"), // mixed case to verify lowercasing
None,
None,
)
.await
.expect("create_invoice");
sqlx::query("UPDATE invoices SET status = 'settled' WHERE id = ?")
.bind(&invoice_id)
.execute(&state.db)
.await
.unwrap();
let license_id = Uuid::new_v4();
let now = Utc::now().to_rfc3339();
repo::create_license(
&state.db,
&license_id.to_string(),
&product.id,
Some(&invoice_id),
&now,
&json!({}),
None,
None,
0,
1,
&[],
false,
Some("buyer@example.com"),
None,
)
.await
.expect("create_license");
// Wrong email → 404 with generic error (does not reveal the
// invoice id exists).
let req = build_request(
"POST",
"/v1/recover",
&[("content-type", "application/json")],
Some(json!({
"invoice_id": invoice_id,
"email": "wrong@example.com",
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"wrong email should 404"
);
// Bogus invoice id → same generic 404.
let req = build_request(
"POST",
"/v1/recover",
&[("content-type", "application/json")],
Some(json!({
"invoice_id": Uuid::new_v4().to_string(),
"email": "buyer@example.com",
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
// Matching pair (case-insensitive email) → 200 with a real
// license key.
let req = build_request(
"POST",
"/v1/recover",
&[("content-type", "application/json")],
Some(json!({
"invoice_id": invoice_id,
"email": "Buyer@Example.com", // different casing on purpose
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"matching pair should succeed"
);
let body = body_json(resp).await;
let license_key = body["license_key"]
.as_str()
.expect("license_key should be present in response")
.to_string();
assert_eq!(body["license_id"], license_id.to_string());
// The recovered key validates round-trip via /v1/validate.
let req = build_request(
"POST",
"/v1/validate",
&[("content-type", "application/json")],
Some(json!({"key": license_key})),
);
let resp = send(&state, req).await;
let validation = body_json(resp).await;
assert_eq!(
validation["ok"], true,
"recovered key must validate cleanly: {validation:?}"
);
// Audit log captured the recovery.
let audit_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM audit_log WHERE action = 'license.recovered'",
)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(audit_count, 1, "recovery must write an audit row");
}