From 54f7ea08b51e5cfe4d159d8f179a769cc4c0089b Mon Sep 17 00:00:00 2001 From: Grant Date: Sat, 9 May 2026 13:58:03 -0500 Subject: [PATCH] =?UTF-8?q?P1=20=E2=80=94=20change-tier=20UX,=20Zaprite=20?= =?UTF-8?q?webhook=20copy,=20self-tier=20guard,=20Lightning=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//change-tier rejects when 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 "/...", 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. --- licensing-service/src/api/mod.rs | 2 +- licensing-service/src/api/upgrade.rs | 24 ++++++ .../src/api/zaprite_authorize.rs | 40 +++++++++ licensing-service/web/index.html | 72 +++++++++++----- startos/actions/configureZaprite.ts | 83 +++++++++++++++++-- startos/actions/index.ts | 8 +- 6 files changed, 201 insertions(+), 28 deletions(-) diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index 6aa9e41..8e1b35f 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -707,7 +707,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
Payment received

Issuing your license…

-

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.

+

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).

diff --git a/licensing-service/src/api/upgrade.rs b/licensing-service/src/api/upgrade.rs index 581b521..560379c 100644 --- a/licensing-service/src/api/upgrade.rs +++ b/licensing-service/src/api/upgrade.rs @@ -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}'")))?; diff --git a/licensing-service/src/api/zaprite_authorize.rs b/licensing-service/src/api/zaprite_authorize.rs index cee288e..c01c344 100644 --- a/licensing-service/src/api/zaprite_authorize.rs +++ b/licensing-service/src/api/zaprite_authorize.rs @@ -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).", }))) } diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index f2d9276..791a9a2 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -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 // the quote preview in the admin UI and rely on the // server-side response after submit. Show a placeholder. - quoteHolder.appendChild(el('p', { class: 'muted', style: 'margin:0; font-size:13px' }, - '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.')) + // Detect direction (upgrade vs downgrade) by comparing the + // 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/ 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)', { 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', { class: 'btn primary', onclick: async () => { - const skip = !!card.querySelector('[name=change_tier_skip_payment]').checked 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) - status.textContent = skip ? 'Applying comp change…' : 'Creating invoice…' + status.textContent = 'Applying…' status.style.color = '' 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', { method: 'POST', body: { to_policy_slug: selectedTargetSlug, - skip_payment: skip, + skip_payment: true, reason, }, }) - if (r.applied) { - status.textContent = 'Applied — license is now on ' + r.to_policy_slug + '.' - } 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) + status.textContent = 'Applied — license is now on ' + r.to_policy_slug + '.' + setTimeout(() => overlay.remove(), 800) } catch (e) { status.textContent = e.message status.style.color = 'var(--danger)' buttonRow.querySelectorAll('button').forEach((b) => b.disabled = false) } }, - }, 'Submit')) + }, 'Apply')) } catch (e) { status.textContent = 'Quote failed: ' + e.message status.style.color = 'var(--danger)' diff --git a/startos/actions/configureZaprite.ts b/startos/actions/configureZaprite.ts index 23d490c..e879cdf 100644 --- a/startos/actions/configureZaprite.ts +++ b/startos/actions/configureZaprite.ts @@ -87,18 +87,89 @@ export const configureZaprite = sdk.Action.withInput( ok: true provider: string base_url: string + webhook_url: string } return { version: '1', title: 'Zaprite connected', message: `Active payment provider is now Zaprite (${body.base_url}).\n\n` + - `Next step: register a webhook in Zaprite's dashboard pointing at:\n` + - `/v1/zaprite/webhook\n\n` + - `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.`, + `Next step — register a webhook in Zaprite's dashboard:\n` + + `1. Open app.zaprite.com → Settings → Webhooks → New webhook.\n` + + `2. Paste this URL exactly (full https://, not just the path):\n\n` + + ` ${body.webhook_url}\n\n` + + `3. Subscribe to order events (payment_received, settled, refunded).\n\n` + + `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, } }, diff --git a/startos/actions/index.ts b/startos/actions/index.ts index fe06293..794b66c 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -22,7 +22,12 @@ import { sdk } from '../sdk' import { activateLicense, showLicenseStatus } from './activateLicense' import { activateBtcpay, activateZaprite } from './activatePaymentProvider' import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay' -import { configureZaprite, disconnectZaprite, zapriteStatus } from './configureZaprite' +import { + configureZaprite, + disconnectZaprite, + showZapriteWebhookSetup, + zapriteStatus, +} from './configureZaprite' import { setOperatorName } from './setOperatorName' import { setWebUiPassword } from './setWebUiPassword' import { showCredentials } from './showCredentials' @@ -39,6 +44,7 @@ export const actions = sdk.Actions.of() // Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker) .addAction(configureZaprite) .addAction(zapriteStatus) + .addAction(showZapriteWebhookSetup) .addAction(activateZaprite) .addAction(disconnectZaprite) // Keysat self-license (Keysat-licenses-Keysat)