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:
@@ -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…</h1>
|
<h1 id="page-title">Issuing your license…</h1>
|
||||||
<p class="lede" id="page-lede">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.</p>
|
<p class="lede" id="page-lede">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).</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">
|
||||||
|
|||||||
@@ -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).",
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)'
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user