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:
@@ -707,7 +707,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
<div class="wrap">
|
||||
<div class="eyebrow">Payment received</div>
|
||||
<h1 id="page-title">Issuing your license…</h1>
|
||||
<p class="lede" id="page-lede">Your Bitcoin payment was received. We’re waiting for it to settle on the network and for the license to be signed. This usually takes under a minute once the next block confirms.</p>
|
||||
<p class="lede" id="page-lede">Your Bitcoin payment was received. We’re waiting for it to settle and for the license to be signed. Lightning settles in seconds; on-chain typically settles in 10–20 minutes (one block confirmation).</p>
|
||||
|
||||
<!-- pending state (default): polling for the license -->
|
||||
<div class="pending-card" id="pending-card">
|
||||
|
||||
@@ -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}'")))?;
|
||||
|
||||
@@ -55,6 +55,18 @@ pub async fn connect(
|
||||
if api_key.is_empty() {
|
||||
return Err(AppError::BadRequest("api_key is required".into()));
|
||||
}
|
||||
|
||||
// Short-circuit: refuse to overwrite an existing config silently.
|
||||
// Operators get confused when they re-run Connect after already
|
||||
// being connected — they expect a "you're already set up" message,
|
||||
// not a form re-prompt that can clobber their working config.
|
||||
if let Ok(Some(_)) = zaprite_config::load(&state.db).await {
|
||||
return Err(AppError::Conflict(
|
||||
"Zaprite is already connected. Run 'Disconnect Zaprite' first \
|
||||
if you want to rotate the API key or switch organizations."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let base_url = req
|
||||
.base_url
|
||||
.as_deref()
|
||||
@@ -118,10 +130,21 @@ pub async fn connect(
|
||||
)
|
||||
.await;
|
||||
|
||||
// Compute the absolute webhook URL so the StartOS Action can
|
||||
// surface the full https://... endpoint to the operator. They
|
||||
// paste this into the Zaprite dashboard exactly. Zaprite's
|
||||
// webhook form requires a full URL, not a path; the previous
|
||||
// copy showed a placeholder which was confusing.
|
||||
let webhook_url = format!(
|
||||
"{}/v1/zaprite/webhook",
|
||||
state.config.public_base_url.trim_end_matches('/')
|
||||
);
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"provider": "zaprite",
|
||||
"base_url": base_url,
|
||||
"webhook_url": webhook_url,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -207,10 +230,27 @@ pub async fn status(
|
||||
Some(p) => Some(p.kind().as_str().to_string()),
|
||||
None => None,
|
||||
};
|
||||
let webhook_url = format!(
|
||||
"{}/v1/zaprite/webhook",
|
||||
state.config.public_base_url.trim_end_matches('/')
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"connected": cfg.is_some(),
|
||||
"active_provider": active_provider,
|
||||
"base_url": cfg.as_ref().map(|c| c.base_url.clone()),
|
||||
"webhook_id": cfg.as_ref().and_then(|c| c.webhook_id.clone()),
|
||||
// Surfaced unconditionally so an operator who lost the
|
||||
// first-connect message can still find the URL to paste
|
||||
// into Zaprite's dashboard. Webhook-not-yet-registered
|
||||
// doesn't change the URL — it's the same address Zaprite
|
||||
// would POST to once registered.
|
||||
"webhook_url": webhook_url,
|
||||
"webhook_explainer": "Zaprite doesn't sign webhook deliveries. \
|
||||
Keysat authenticates each delivery via the externalUniqId we attach \
|
||||
at order creation, so a webhook configured to ANY URL on your daemon \
|
||||
is safe even without a shared secret. Polling /v1/orders works as a \
|
||||
fallback if you don't register the webhook, but webhooks fire on \
|
||||
payment settle and let Keysat issue the license within a second \
|
||||
instead of the next reconcile-loop tick (every 60s).",
|
||||
})))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user