P1 — change-tier UX, Zaprite webhook copy, self-tier guard, Lightning copy

Bundle of bugfixes from the P1 testing batch. None individually
huge; together they close several "tested it, hit a sharp edge"
items.

1. Change-tier modal — kill the paid path from UI
   The Apply-as-comp toggle is gone. Admin tier changes always
   apply as comp now. The reasoning (per Grant's testing): admin
   tier changes are operator-driven, payment has either already
   happened off-rails or it's a comp; the "admin generates
   invoice and forwards URL" flow is a tiny niche that just
   produces orphan invoices when the modal gets dismissed.
   Buyers who want to pay use the SDK's /v1/upgrade.
   The API path is unchanged for back-compat with scripted
   operators (skip_payment defaults to true here).

2. Change-tier modal — downgrade detection + warning banner
   Detects target.tier_rank < current.tier_rank (or price-diff
   when ranks aren't set), renders a yellow warning card listing
   the entitlements the buyer is about to lose, and confirms via
   browser dialog before submit. Operator sees what they're
   doing.

3. Self-tier guard on admin change-tier
   POST /v1/admin/licenses/<id>/change-tier rejects when <id>
   is the daemon's own self_license. Avoids the recursion Grant
   hit when trying to downgrade himself: the on-disk signed key
   is the source-of-truth at boot, so the DB tier_change just
   produces a half-applied state. Error message points at the
   right paths (re-mint via master Keysat OR rename
   /data/keysat-license.txt for testing). With the P0 self-tier
   live-refresh in place the recursion is now fully resolved
   anyway, but the guard is good belt-and-suspenders for
   operator clarity.

4. Zaprite webhook — full URL in copy + persistent action
   - The Connect Zaprite action now shows the EXACT
     https://your-keysat-url/v1/zaprite/webhook URL to paste
     into Zaprite's dashboard. Previous copy showed a
     placeholder "<your Keysat public URL>/...", which Zaprite's
     form rejects (it requires full https://). Daemon's
     /v1/admin/zaprite/connect now returns webhook_url; the
     action displays it.
   - New "Show Zaprite webhook setup" StartOS Action — operators
     who skipped the step on first connect, or who lost the
     output, can run this any time and get the URL again.
   - Full explainer of what webhooks unlock vs polling-only:
     "without webhooks, Keysat polls /v1/orders every 60s, so
     license issuance lags settle by up to a minute; with
     webhooks, ~1s." Lives on /v1/admin/zaprite/status response
     as `webhook_explainer` + in the action's display text.

5. Connect-while-connected short-circuit
   POST /v1/admin/zaprite/connect now returns 409 Conflict with a
   clear "already connected — disconnect first" message instead
   of silently overwriting an existing config. (BTCPay's
   start_connect already had this guard since the durable
   provider switch work.)

6. Lightning vs on-chain copy on the wait page
   /thank-you was hard-coded to "next block confirms" — wrong
   for Lightning payments (instant) and confusing in the common
   case where buyers paid via Lightning and saw a "waiting for
   block confirmation" message. Updated to: "Lightning settles
   in seconds; on-chain typically settles in 10-20 minutes (one
   block confirmation)." Method-aware copy (parsed from the
   provider's invoice payload) is a deeper fix but out of scope
   here — this gets the operator-facing accuracy right today.

Test count unchanged; all 77 still passing.
This commit is contained in:
Grant
2026-05-09 13:58:03 -05:00
parent 2fbd36fac6
commit 54f7ea08b5
6 changed files with 201 additions and 28 deletions
+24
View File
@@ -313,6 +313,30 @@ pub async fn admin_change(
let (ip, ua) = request_context(&headers);
let reason = body.reason.as_deref().filter(|s| !s.trim().is_empty());
// Refuse to change-tier on the daemon's OWN self-license. The
// signed key on disk is the immutable proof-of-tier and won't
// reflect any DB change anyway — operators trying to "downgrade
// themselves to test gating" hit a recursion that produces a
// confusing half-applied state. Live-refresh from the DB does
// pick up the new entitlements, but the operator should drive
// self-tier changes through the proper re-mint flow on the
// master Keysat instead. For now, refuse with a clear message.
{
let current = state.self_tier.read().await.clone();
if let crate::license_self::Tier::Licensed { license_id: self_id, .. } = current {
if self_id.to_string() == license_id {
return Err(AppError::BadRequest(
"cannot change tier on the daemon's own self-license — \
re-issue a new key from the master Keysat and activate it via \
'Activate Keysat license' instead. Or, for testing Creator-tier \
gates, temporarily move /data/keysat-license.txt aside and \
restart Keysat to boot Unlicensed."
.into(),
));
}
}
}
let license = crate::db::repo::get_license_by_id(&state.db, &license_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("license '{license_id}'")))?;