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)