v0.2.0:41 — Patron implies Pro; BTCPay Connect back to one-click authorize

Patron entitlement now expands to the full Pro surface
(unlimited_products / _policies / _codes, recurring_billing,
zaprite_payments) in tier::current(). Existing Patron customers get
the implied entitlements without re-issuing.

BTCPay Connect: replace the four-field paste form (Base URL + API key
+ Store id + Webhook secret) with the original one-click button that
fetches an authorize URL from /v1/admin/btcpay/connect, opens it in a
new tab, and polls /v1/admin/btcpay/status until the BTCPay callback
finishes. Zaprite path unchanged.
This commit is contained in:
Grant
2026-05-12 12:12:54 -05:00
parent d927e4940f
commit a3662de6d8
3 changed files with 140 additions and 3 deletions
+25 -1
View File
@@ -90,12 +90,36 @@ impl TierInfo {
/// to be fixed.
pub async fn current(state: &AppState) -> TierInfo {
let tier = state.self_tier.read().await;
let entitlements = match &*tier {
let mut entitlements = match &*tier {
Tier::Licensed { entitlements, .. } => entitlements.clone(),
Tier::Unlicensed { .. } => Vec::new(),
};
drop(tier);
// Patron implies Pro by design (see module docstring: "Patron: same
// feature surface as Pro, plus a `patron` entitlement..."). Without
// this expansion, every downstream `tier.has(<pro-entitlement>)`
// check requires the Patron POLICY on the master Keysat to
// redundantly list every Pro entitlement. That's brittle: a single
// missing slug on the policy (e.g. operator forgets
// `zaprite_payments`) breaks Pro-equivalence for every Patron
// customer. Treating `patron` as a strict superset of Pro at the
// resolution layer means policy authors can list `patron` alone
// and have everything Pro grants flow through automatically.
if entitlements.iter().any(|e| e == "patron") {
for implied in [
"unlimited_products",
"unlimited_policies",
"unlimited_codes",
"recurring_billing",
"zaprite_payments",
] {
if !entitlements.iter().any(|e| e == implied) {
entitlements.push(implied.to_string());
}
}
}
let label: &'static str;
let display_name: &'static str;
if entitlements.iter().any(|e| e == "patron") {
+106 -1
View File
@@ -5794,7 +5794,13 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
} else {
buttons.push(el('button', {
class: 'btn sm primary',
onclick: () => openConnectModal(name, label, () => renderPaymentProvidersCard(host)),
onclick: () => {
if (name === 'btcpay') {
openBtcpayConnectModal(() => renderPaymentProvidersCard(host))
} else {
openConnectModal(name, label, () => renderPaymentProvidersCard(host))
}
},
}, 'Connect'))
}
return el('div', {
@@ -5898,6 +5904,105 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
document.body.appendChild(overlay)
}
// BTCPay one-click connect modal.
//
// Replaces the manual base-URL / API-key / store-id / webhook-secret
// paste flow. BTCPay's Greenfield API has a native consent endpoint
// (/api-keys/authorize) — the daemon's /v1/admin/btcpay/connect returns
// a pre-built authorize URL we open in a new tab. After the operator
// clicks Authorize in BTCPay, BTCPay redirects to our
// /v1/btcpay/authorize/callback, which auto-detects the store,
// registers the webhook, and persists everything. We poll
// /v1/admin/btcpay/status here every 2.5s so the modal closes itself
// the moment connection lands.
async function openBtcpayConnectModal(onSuccess) {
const overlay = el('div', {
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
'display:flex; align-items:center; justify-content:center; padding:20px; overflow-y:auto;',
})
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px; min-height:18px' }, '')
let pollTimer = null
function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } }
function close() { stopPolling(); overlay.remove() }
const openBtn = el('button', { class: 'btn primary' }, 'Open BTCPay to authorize')
const cancelBtn = el('button', { class: 'btn secondary' }, 'Cancel')
const urlBox = el('div', {
style: 'display:none; margin-top:12px; padding:10px 12px; background:var(--cream-100); ' +
'border:1px solid var(--border-1); border-radius:8px; font-family:var(--font-mono); ' +
'font-size:11.5px; word-break:break-all; color:var(--ink-700)',
}, '')
const urlCopy = el('button', {
class: 'btn sm secondary',
style: 'display:none; margin-top:6px',
}, 'Copy URL')
openBtn.addEventListener('click', async () => {
openBtn.disabled = true
status.textContent = 'Requesting authorize URL from BTCPay…'
let authorizeUrl
try {
const resp = await api('/v1/admin/btcpay/connect', { method: 'POST', body: {} })
authorizeUrl = resp.authorize_url
if (!authorizeUrl) throw new Error('No authorize_url in response')
} catch (e) {
status.textContent = e.message || 'Could not start authorize flow.'
openBtn.disabled = false
return
}
window.open(authorizeUrl, '_blank', 'noopener')
urlBox.textContent = authorizeUrl
urlBox.style.display = 'block'
urlCopy.style.display = 'inline-block'
urlCopy.onclick = async () => {
try {
await navigator.clipboard.writeText(authorizeUrl)
urlCopy.textContent = 'Copied'
setTimeout(() => { urlCopy.textContent = 'Copy URL' }, 1400)
} catch (_) {}
}
openBtn.textContent = 'Re-open authorize page'
openBtn.onclick = () => window.open(authorizeUrl, '_blank', 'noopener')
openBtn.disabled = false
status.textContent = 'Approve in the new tab, then return here — this dialog will close automatically.'
pollTimer = setInterval(async () => {
try {
const s = await api('/v1/admin/btcpay/status')
if (s && s.connected) {
close()
onSuccess && onSuccess()
}
} catch (_) {
// Non-fatal — keep polling. BTCPay callback might still be
// mid-flight or the operator hasn't approved yet.
}
}, 2500)
})
cancelBtn.addEventListener('click', close)
const cardEl = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); border-radius:12px; max-width:540px; width:100%; padding:24px; ' +
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20); max-height:90vh; overflow-y:auto;',
}, [
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Connect'),
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:18px; margin:0 0 6px; color:var(--navy-950);' },
'Connect BTCPay Server'),
el('p', { class: 'muted', style: 'margin:6px 0 12px; font-size:13px; line-height:1.5' },
'One-click connect via BTCPays native authorize flow. Click below to open a consent page in your BTCPay server; ' +
'after you click Authorize there, Keysat auto-detects the store, registers the webhook, and finishes setup. ' +
'You dont need to copy an API key or store id anywhere.'),
openBtn,
status,
urlBox,
urlCopy,
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [cancelBtn]),
])
overlay.appendChild(cardEl)
overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
document.body.appendChild(overlay)
}
async function renderApiKeysCard(host) {
host.innerHTML = ''
let keys = []
+9 -1
View File
@@ -58,6 +58,14 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
const ROUTINE_NOTES = [
'0.2.0:41 — **Two fixes: Patron tier now implies the full Pro feature surface, and BTCPay Connect is back to one-click authorize.** Both came from operator-side bugs that the admin-UI redesign exposed.',
'',
'**Patron implies Pro at the resolution layer.** Previously, every `tier.has(<pro-entitlement>)` check required the Patron POLICY on the master Keysat to redundantly list every Pro entitlement (`unlimited_products`, `unlimited_policies`, `unlimited_codes`, `recurring_billing`, `zaprite_payments`) — if the operator forgot even one slug on the Patron policy, every Patron customer was silently locked out of that feature. The Zaprite gate caught this in the wild: a Patron license without `zaprite_payments` got an "Upgrade to Pro" CTA on the payment-providers page. Fixed at the right layer: `tier::current()` now expands `patron` into the full Pro entitlement set on read, so a Patron policy can list just `patron` and have everything Pro grants flow through automatically. Existing Patron customers get the implied entitlements without re-issuing a license. Recommended cleanup: also list the entitlements explicitly on the Patron policy itself so the buy-page tier card stays informative — but the gate behavior no longer depends on it.',
'',
'**BTCPay Connect: one-click authorize flow restored.** When BTCPay setup moved from a StartOS action (where it was a one-click `Connect BTCPay` that returned a URL the operator opened in their browser to authorize, with the API key / store id / webhook secret auto-detected by Keysat) to the admin UIs Payment Providers card, it regressed to a four-field paste form asking for Base URL, API key, Store id, and Webhook secret. The daemon-side `/v1/admin/btcpay/connect` endpoint never changed — it still returns an `authorize_url` for BTCPays consent page — the form just stopped using it. Rewrote the BTCPay path in the modal: a "Connect BTCPay Server" dialog now has one primary button, "Open BTCPay to authorize". On click, it requests the authorize URL, opens it in a new tab, displays it as copyable text (for operators on a different device than their browser), and polls `/v1/admin/btcpay/status` every 2.5 seconds. The moment BTCPays callback lands and the store/webhook are persisted, the modal closes itself and the Payment Providers card re-renders showing BTCPay connected. Zaprites connect path is unchanged (Zaprite has no OAuth-style consent endpoint; an API key paste is still required).',
'',
'**Upgrade path.** Drop-in. No schema, no SDK change. Operators currently stuck on the four-field BTCPay form get the new one-click button automatically; the manual-fields path is removed for BTCPay since it never actually wrote those fields server-side anyway.',
'',
'0.2.0:40 — **Discount-code slot reaper plugs the abandoned-cart leak.** When a buyer clicked Pay with Bitcoin with a discount code applied, the daemon reserved a slot on that code (incrementing `used_count`) BEFORE creating the BTCPay invoice. This is the right pessimistic-lock behavior — prevents two buyers from racing for the last slot of a limited code — but it meant abandoned checkouts only freed the slot when BTCPay later fired `InvoiceExpired`. If that webhook never landed (network blip, daemon offline at the firing moment, misconfigured webhook URL), the slot leaked forever. New 5-minute background reaper closes both holes: scans `discount_redemptions` where status=\'pending\' and the linked invoice is either in a terminal failure state (\'expired\' / \'invalid\') OR has been sitting in \'pending\' for more than 30 minutes, and cancels each one — flipping the redemption to \'cancelled\' and decrementing the code\'s `used_count` so the slot is available again. 30-min threshold covers BTCPay\'s default 15-min invoice expiry plus webhook-delivery buffer. Lives alongside the existing hourly session reaper in `main.rs`. Internal-only; no API or schema change. Operator-visible only in the sense that limited-discount slots no longer drift over time.',
'',
'0.2.0:39 — **Buy page now renders a tier card for single-public-policy products.** Previously the tier picker only rendered when a product had two or more public policies; single-public-policy products fell back to a bare price card + form, swallowing all the operator-configured entitlements, marketing bullets, and tier descriptions. Fixed: render a single centered tier card (new `.tiers-1` grid class, ~480px max-width) whenever there\'s at least one public policy. Operators who keep most tiers private and only expose one (e.g. "Pro" public, "Core" and "Max" admin-only) now see the same rich tier-card render that multi-tier products get. The price card below still renders unchanged as the buy-confirmation summary.',
@@ -509,7 +517,7 @@ const ROUTINE_NOTES = [
].join('\n\n')
export const v0_2_0 = VersionInfo.of({
version: '0.2.0:40',
version: '0.2.0:41',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under