From a3662de6d88c4b7cf164ceb867e168154f633e89 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 12 May 2026 12:12:54 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0:41=20=E2=80=94=20Patron=20implies=20Pro;?= =?UTF-8?q?=20BTCPay=20Connect=20back=20to=20one-click=20authorize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- licensing-service/src/api/tier.rs | 26 +++++++- licensing-service/web/index.html | 107 +++++++++++++++++++++++++++++- startos/versions/v0.2.0.ts | 10 ++- 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/licensing-service/src/api/tier.rs b/licensing-service/src/api/tier.rs index 09cd0e3..d4c5a3b 100644 --- a/licensing-service/src/api/tier.rs +++ b/licensing-service/src/api/tier.rs @@ -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()` + // 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") { diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 86322ac..c8114da 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -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 BTCPay’s 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 don’t 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 = [] diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index 03f053b..6a322ab 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -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()` 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 UI’s 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 BTCPay’s 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 BTCPay’s callback lands and the store/webhook are persisted, the modal closes itself and the Payment Providers card re-renders showing BTCPay connected. Zaprite’s 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