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
+1 -1
View File
@@ -707,7 +707,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
<div class="wrap"> <div class="wrap">
<div class="eyebrow">Payment received</div> <div class="eyebrow">Payment received</div>
<h1 id="page-title">Issuing your license&hellip;</h1> <h1 id="page-title">Issuing your license&hellip;</h1>
<p class="lede" id="page-lede">Your Bitcoin payment was received. We&rsquo;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&rsquo;re waiting for it to settle and for the license to be signed. Lightning settles in seconds; on-chain typically settles in 10&ndash;20 minutes (one block confirmation).</p>
<!-- pending state (default): polling for the license --> <!-- pending state (default): polling for the license -->
<div class="pending-card" id="pending-card"> <div class="pending-card" id="pending-card">
+24
View File
@@ -313,6 +313,30 @@ pub async fn admin_change(
let (ip, ua) = request_context(&headers); let (ip, ua) = request_context(&headers);
let reason = body.reason.as_deref().filter(|s| !s.trim().is_empty()); 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) let license = crate::db::repo::get_license_by_id(&state.db, &license_id)
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("license '{license_id}'")))?; .ok_or_else(|| AppError::NotFound(format!("license '{license_id}'")))?;
@@ -55,6 +55,18 @@ pub async fn connect(
if api_key.is_empty() { if api_key.is_empty() {
return Err(AppError::BadRequest("api_key is required".into())); 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 let base_url = req
.base_url .base_url
.as_deref() .as_deref()
@@ -118,10 +130,21 @@ pub async fn connect(
) )
.await; .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(Json(json!({
"ok": true, "ok": true,
"provider": "zaprite", "provider": "zaprite",
"base_url": base_url, "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()), Some(p) => Some(p.kind().as_str().to_string()),
None => None, None => None,
}; };
let webhook_url = format!(
"{}/v1/zaprite/webhook",
state.config.public_base_url.trim_end_matches('/')
);
Ok(Json(json!({ Ok(Json(json!({
"connected": cfg.is_some(), "connected": cfg.is_some(),
"active_provider": active_provider, "active_provider": active_provider,
"base_url": cfg.as_ref().map(|c| c.base_url.clone()), "base_url": cfg.as_ref().map(|c| c.base_url.clone()),
"webhook_id": cfg.as_ref().and_then(|c| c.webhook_id.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).",
}))) })))
} }
+52 -20
View File
@@ -1420,11 +1420,46 @@ The request will be refused if there are licenses or invoices tied to it — use
// buyer key, which the admin doesn't have. So: we skip // buyer key, which the admin doesn't have. So: we skip
// the quote preview in the admin UI and rely on the // the quote preview in the admin UI and rely on the
// server-side response after submit. Show a placeholder. // server-side response after submit. Show a placeholder.
quoteHolder.appendChild(el('p', { class: 'muted', style: 'margin:0; font-size:13px' }, // Detect direction (upgrade vs downgrade) by comparing the
'The server will compute the prorated charge in the listed currency on submit. Toggle "Apply as comp" below to skip the invoice and move the license immediately at no charge.')) // current and target policies' price_sats_override (or rank
// when both ranked). Drives a "downgrade warning" banner so
// the operator sees what entitlements the buyer is about to
// lose. Cheap client-side compute; the server still
// re-validates.
const targetPol = allPolicies.find((p) => p.slug === selectedTargetSlug)
const currentPol = allPolicies.find((p) => p.slug === currentPolicySlug)
let isDowngrade = false
if (targetPol && currentPol) {
if (targetPol.tier_rank != null && currentPol.tier_rank != null) {
isDowngrade = targetPol.tier_rank < currentPol.tier_rank
} else {
const a = currentPol.price_sats_override != null ? currentPol.price_sats_override : 0
const b = targetPol.price_sats_override != null ? targetPol.price_sats_override : 0
isDowngrade = b < a
}
}
if (isDowngrade && targetPol && currentPol) {
// Build a list of entitlements the buyer is about to lose.
const losing = (currentPol.entitlements || []).filter(
(e) => !(targetPol.entitlements || []).includes(e),
)
const losingLine = losing.length > 0
? 'Buyer will LOSE these entitlements: ' + losing.join(', ') + '.'
: 'Buyer keeps the same entitlements.'
quoteHolder.appendChild(plainCard([
el('div', { style: 'color:#7a5814; font-weight:600; margin-bottom:6px' },
'⚠ Downgrade'),
el('p', { style: 'margin:0 0 8px; font-size:13px' },
'You\'re moving this license from ' + currentPol.name + ' → ' + targetPol.name + '. ' +
losingLine + ' The buyer will NOT be refunded automatically — handle any refund out of band.'),
]))
} else {
quoteHolder.appendChild(el('p', { class: 'muted', style: 'margin:0; font-size:13px' },
'Admin tier changes always apply as comp (no invoice, license flips immediately). ' +
'For paid upgrades, point the buyer at /buy/<slug> or have their app drive the SDK\'s in-app purchase flow.'))
}
const compToggle = formCheckbox('change_tier_skip_payment', 'Apply as comp (skip_payment=true — no invoice, license flips immediately)')
quoteHolder.appendChild(compToggle)
const reasonField = formInput('change_tier_reason', 'Audit reason (optional)', { const reasonField = formInput('change_tier_reason', 'Audit reason (optional)', {
hint: 'Free-form note. Stored on the tier_changes row + audit_log.', hint: 'Free-form note. Stored on the tier_changes row + audit_log.',
}) })
@@ -1433,39 +1468,36 @@ The request will be refused if there are licenses or invoices tied to it — use
buttonRow.appendChild(el('button', { buttonRow.appendChild(el('button', {
class: 'btn primary', class: 'btn primary',
onclick: async () => { onclick: async () => {
const skip = !!card.querySelector('[name=change_tier_skip_payment]').checked
const reason = (card.querySelector('[name=change_tier_reason]').value || '').trim() || null const reason = (card.querySelector('[name=change_tier_reason]').value || '').trim() || null
// Confirm on downgrades — operator should explicitly OK
// before the buyer loses entitlements.
if (isDowngrade && !confirm('Confirm downgrade? Buyer loses entitlements without refund.')) {
return
}
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = true) buttonRow.querySelectorAll('button').forEach((b) => b.disabled = true)
status.textContent = skip ? 'Applying comp change…' : 'Creating invoice…' status.textContent = 'Applying…'
status.style.color = '' status.style.color = ''
try { try {
// Always apply as comp from the admin UI. Paid admin
// tier changes are admin-API-only (back-compat) — see
// KEYSAT_INTEGRATION.md re: SDK-driven buyer upgrades.
const r = await api('/v1/admin/licenses/' + license.id + '/change-tier', { const r = await api('/v1/admin/licenses/' + license.id + '/change-tier', {
method: 'POST', method: 'POST',
body: { body: {
to_policy_slug: selectedTargetSlug, to_policy_slug: selectedTargetSlug,
skip_payment: skip, skip_payment: true,
reason, reason,
}, },
}) })
if (r.applied) { status.textContent = 'Applied — license is now on ' + r.to_policy_slug + '.'
status.textContent = 'Applied — license is now on ' + r.to_policy_slug + '.' setTimeout(() => overlay.remove(), 800)
} else {
const url = r.checkout_url || ''
status.innerHTML = ''
status.appendChild(el('div', null, 'Invoice created. Forward this URL to the buyer:'))
const link = el('a', { href: url, target: '_blank', style: 'word-break:break-all' }, url)
status.appendChild(link)
}
setTimeout(() => {
if (skip) overlay.remove()
}, 800)
} catch (e) { } catch (e) {
status.textContent = e.message status.textContent = e.message
status.style.color = 'var(--danger)' status.style.color = 'var(--danger)'
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = false) buttonRow.querySelectorAll('button').forEach((b) => b.disabled = false)
} }
}, },
}, 'Submit')) }, 'Apply'))
} catch (e) { } catch (e) {
status.textContent = 'Quote failed: ' + e.message status.textContent = 'Quote failed: ' + e.message
status.style.color = 'var(--danger)' status.style.color = 'var(--danger)'
+77 -6
View File
@@ -87,18 +87,89 @@ export const configureZaprite = sdk.Action.withInput(
ok: true ok: true
provider: string provider: string
base_url: string base_url: string
webhook_url: string
} }
return { return {
version: '1', version: '1',
title: 'Zaprite connected', title: 'Zaprite connected',
message: message:
`Active payment provider is now Zaprite (${body.base_url}).\n\n` + `Active payment provider is now Zaprite (${body.base_url}).\n\n` +
`Next step: register a webhook in Zaprite's dashboard pointing at:\n` + `Next step register a webhook in Zaprite's dashboard:\n` +
`<your Keysat public URL>/v1/zaprite/webhook\n\n` + `1. Open app.zaprite.com → Settings → Webhooks → New webhook.\n` +
`Zaprite doesn't sign webhook deliveries; Keysat authenticates ` + `2. Paste this URL exactly (full https://, not just the path):\n\n` +
`each delivery via the externalUniqId we attach at order ` + ` ${body.webhook_url}\n\n` +
`creation, so a webhook configured to ANY URL on your daemon ` + `3. Subscribe to order events (payment_received, settled, refunded).\n\n` +
`is safe even without a shared secret.`, `Why webhooks: without them, Keysat falls back to polling Zaprite's ` +
`/v1/orders every 60 seconds, so license issuance can lag a settle ` +
`event by up to a minute. With webhooks, Keysat issues the license ` +
`within ~1 second of payment.\n\n` +
`Security: 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.\n\n` +
`Lost this message? Run "Show Zaprite webhook setup" to see the URL again.`,
result: null,
}
},
)
/**
* Persistent surface for the webhook URL — operators who skipped the
* step on first connect, or who just want to verify which URL Zaprite
* should be posting to, run this and get the exact value to paste.
*/
export const showZapriteWebhookSetup = sdk.Action.withoutInput(
'show-zaprite-webhook-setup',
async () => ({
name: 'Show Zaprite webhook setup',
description:
"Display the full webhook URL to register in your Zaprite dashboard, " +
"plus an explanation of what webhooks do that polling doesn't. " +
"Useful if you skipped the step on first Connect.",
warning: null,
allowedStatuses: 'only-running',
group: 'Zaprite',
visibility: 'enabled',
}),
async ({ effects: _effects }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/zaprite/status',
{ method: 'GET' },
)
if (!resp.ok) {
throw new Error(
`Could not read status: HTTP ${resp.status}${await resp.text()}`,
)
}
const body = (await resp.json()) as {
connected: boolean
webhook_url: string
webhook_explainer: string
}
if (!body.connected) {
return {
version: '1',
title: 'Zaprite not connected',
message:
'Connect Zaprite first via the Connect Zaprite action. ' +
'Once connected, re-run this action to see the webhook URL ' +
'to register in your Zaprite dashboard.',
result: null,
}
}
return {
version: '1',
title: 'Zaprite webhook setup',
message:
`Paste this URL exactly into Zaprite's webhook form ` +
`(app.zaprite.com → Settings → Webhooks → New webhook):\n\n` +
` ${body.webhook_url}\n\n` +
`Subscribe to order events (payment_received, settled, refunded).\n\n` +
body.webhook_explainer,
result: null, result: null,
} }
}, },
+7 -1
View File
@@ -22,7 +22,12 @@ import { sdk } from '../sdk'
import { activateLicense, showLicenseStatus } from './activateLicense' import { activateLicense, showLicenseStatus } from './activateLicense'
import { activateBtcpay, activateZaprite } from './activatePaymentProvider' import { activateBtcpay, activateZaprite } from './activatePaymentProvider'
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay' import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
import { configureZaprite, disconnectZaprite, zapriteStatus } from './configureZaprite' import {
configureZaprite,
disconnectZaprite,
showZapriteWebhookSetup,
zapriteStatus,
} from './configureZaprite'
import { setOperatorName } from './setOperatorName' import { setOperatorName } from './setOperatorName'
import { setWebUiPassword } from './setWebUiPassword' import { setWebUiPassword } from './setWebUiPassword'
import { showCredentials } from './showCredentials' import { showCredentials } from './showCredentials'
@@ -39,6 +44,7 @@ export const actions = sdk.Actions.of()
// Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker) // Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker)
.addAction(configureZaprite) .addAction(configureZaprite)
.addAction(zapriteStatus) .addAction(zapriteStatus)
.addAction(showZapriteWebhookSetup)
.addAction(activateZaprite) .addAction(activateZaprite)
.addAction(disconnectZaprite) .addAction(disconnectZaprite)
// Keysat self-license (Keysat-licenses-Keysat) // Keysat self-license (Keysat-licenses-Keysat)