6ac118ae70
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages, discount codes, free-license redemption, Apply-discount UX, self-licensing, and v0.1.0 release notes.
228 lines
8.0 KiB
TypeScript
228 lines
8.0 KiB
TypeScript
// Action: one-click "Connect BTCPay".
|
|
//
|
|
// Instead of asking the operator to generate and paste an API key, we use
|
|
// BTCPay's built-in authorize flow:
|
|
// 1. Action calls POST /v1/admin/btcpay/connect on the local daemon.
|
|
// 2. Daemon returns an authorize URL pointing at the sibling BTCPay
|
|
// instance, with the permissions we need pre-filled.
|
|
// 3. Operator opens that URL in their browser, approves on BTCPay's
|
|
// consent page, and BTCPay calls back into /v1/btcpay/authorize/callback
|
|
// with the freshly-minted API key.
|
|
// 4. Daemon auto-detects the store, registers the webhook, and persists
|
|
// everything.
|
|
//
|
|
// The operator never sees or types an API key, store id, or webhook secret.
|
|
|
|
import { sdk } from '../sdk'
|
|
import { store } from '../fileModels/store'
|
|
import { adminCall, LICENSING_URL } from '../utils'
|
|
|
|
export const configureBtcpay = sdk.Action.withoutInput(
|
|
'configure-btcpay',
|
|
async () => ({
|
|
name: 'Connect BTCPay',
|
|
description:
|
|
"One-click connect to your BTCPay Server. Opens a consent page in " +
|
|
"your browser where you click 'Authorize'; Keysat then auto-detects " +
|
|
"your store and registers the webhook.",
|
|
warning: null,
|
|
allowedStatuses: 'only-running',
|
|
group: 'BTCPay',
|
|
visibility: 'enabled',
|
|
}),
|
|
async () => {
|
|
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/btcpay/connect',
|
|
{ method: 'POST' },
|
|
)
|
|
if (!resp.ok) {
|
|
throw new Error(`Connect initialisation failed: HTTP ${resp.status} — ${await resp.text()}`)
|
|
}
|
|
const body = (await resp.json()) as { authorize_url: string }
|
|
|
|
return {
|
|
version: '1',
|
|
title: 'Approve on BTCPay to finish connecting',
|
|
message:
|
|
'Open the URL below in your browser. You will be taken to your ' +
|
|
'BTCPay Server, where you click "Authorize". After that BTCPay ' +
|
|
'sends the API key back to Keysat automatically — you do not ' +
|
|
'need to copy anything.\n\nYou can confirm the connection succeeded ' +
|
|
'with the "Check BTCPay connection" action once approval is complete.',
|
|
result: {
|
|
type: 'single',
|
|
value: body.authorize_url,
|
|
copyable: true,
|
|
qr: false,
|
|
masked: false,
|
|
},
|
|
}
|
|
},
|
|
)
|
|
|
|
/** Replay id used by init/index.ts when surfacing the BTCPay setup task. */
|
|
const BTCPAY_SETUP_TASK_ID = 'btcpay-initial-setup'
|
|
|
|
/** Disconnect BTCPay — clean revocation path for re-authorize cases. */
|
|
export const disconnectBtcpay = sdk.Action.withoutInput(
|
|
'disconnect-btcpay',
|
|
async () => ({
|
|
name: 'Disconnect BTCPay',
|
|
description:
|
|
'Disconnect Keysat from your BTCPay Server: revoke the API key, ' +
|
|
'delete the registered webhook, and clear local connection state. ' +
|
|
"Run this before 'Connect BTCPay' if you want to re-authorize " +
|
|
'(e.g., to switch stores or rotate the API key). Existing license ' +
|
|
'keys, products, and policies are unaffected.',
|
|
warning:
|
|
'Until you re-run "Connect BTCPay" after this, new purchases will ' +
|
|
'return 503 (BTCPay not configured). Already-issued license keys ' +
|
|
'continue to validate normally.',
|
|
allowedStatuses: 'only-running',
|
|
group: 'BTCPay',
|
|
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/btcpay/disconnect',
|
|
{ method: 'POST' },
|
|
)
|
|
if (!resp.ok) {
|
|
throw new Error(`Disconnect failed: HTTP ${resp.status} — ${await resp.text()}`)
|
|
}
|
|
const body = (await resp.json()) as
|
|
| { ok: true; noop: true; message: string }
|
|
| {
|
|
ok: true
|
|
noop: false
|
|
store_id: string | null
|
|
webhook_id: string | null
|
|
warnings: string[]
|
|
}
|
|
if ('noop' in body && body.noop) {
|
|
return {
|
|
version: '1',
|
|
title: 'Already disconnected',
|
|
message: body.message,
|
|
result: null,
|
|
}
|
|
}
|
|
const b = body as {
|
|
ok: true
|
|
noop: false
|
|
store_id: string | null
|
|
webhook_id: string | null
|
|
warnings: string[]
|
|
}
|
|
const warningsBlock = b.warnings.length > 0
|
|
? `\n\nWarnings:\n${b.warnings.map((w) => `• ${w}`).join('\n')}`
|
|
: ''
|
|
return {
|
|
version: '1',
|
|
title: 'BTCPay disconnected',
|
|
message:
|
|
`Local BTCPay connection cleared. ` +
|
|
`Store id was ${b.store_id ?? '(unknown)'}, webhook id was ${b.webhook_id ?? '(none)'}. ` +
|
|
`You can now run "Connect BTCPay" again to re-authorize.${warningsBlock}`,
|
|
result: null,
|
|
}
|
|
},
|
|
)
|
|
|
|
/** Optional companion action: show current BTCPay connection state. */
|
|
export const btcpayStatus = sdk.Action.withoutInput(
|
|
'btcpay-status',
|
|
async () => ({
|
|
name: 'Check BTCPay connection',
|
|
description: 'Shows whether BTCPay is currently connected, and the store id.',
|
|
warning: null,
|
|
allowedStatuses: 'only-running',
|
|
group: 'BTCPay',
|
|
visibility: 'enabled',
|
|
}),
|
|
async ({ 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/btcpay/status',
|
|
{ method: 'GET' },
|
|
)
|
|
if (!resp.ok) {
|
|
throw new Error(`Status check failed: HTTP ${resp.status} — ${await resp.text()}`)
|
|
}
|
|
const body = (await resp.json()) as
|
|
| { connected: false }
|
|
| { connected: true; store_id: string; webhook_id: string | null; base_url: string }
|
|
|
|
if (!body.connected) {
|
|
return {
|
|
version: '1',
|
|
title: 'Not connected',
|
|
message: 'BTCPay is not connected yet. Run the "Connect BTCPay" action to authorize.',
|
|
result: null,
|
|
}
|
|
}
|
|
|
|
// BTCPay is connected — clear the install-time setup task so it
|
|
// disappears from the dashboard. clearTask is idempotent and
|
|
// tolerates being called when no such task exists, so this is safe
|
|
// every time btcpayStatus is run.
|
|
try {
|
|
await sdk.action.clearTask(effects, BTCPAY_SETUP_TASK_ID)
|
|
} catch (_) {
|
|
// Non-fatal — we still report status.
|
|
}
|
|
|
|
// Also check whether BTCPay's store has any payment methods (wallet
|
|
// / Lightning) configured. A connected store with zero payment
|
|
// methods can't actually issue invoices — that's the trap that
|
|
// surfaces as "BTC-CHAIN: Payment method unavailable" when buyers
|
|
// try to purchase. Surface the situation here so the operator
|
|
// discovers it BEFORE a customer hits a broken purchase flow.
|
|
let walletNote = ''
|
|
try {
|
|
const pmResp = await adminCall(
|
|
LICENSING_URL,
|
|
storeData.admin_api_key,
|
|
'/v1/admin/btcpay/payment-methods',
|
|
{ method: 'GET' },
|
|
)
|
|
if (pmResp.ok) {
|
|
const pmBody = (await pmResp.json()) as { count: number }
|
|
if (pmBody.count === 0) {
|
|
walletNote =
|
|
`\n\n⚠ NO WALLET CONFIGURED on this BTCPay store. Buyers won't ` +
|
|
`be able to pay until you set one up.\n` +
|
|
`Open your BTCPay store settings (${body.base_url.replace(/^http:\/\//, 'http://').replace(/^https:\/\//, 'https://')}/stores/${body.store_id}) ` +
|
|
`→ Wallets / Lightning, then come back and re-run "Check BTCPay connection".`
|
|
} else {
|
|
walletNote = `\n\n✓ ${pmBody.count} payment method(s) configured.`
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Non-fatal: payment-method check is informational.
|
|
}
|
|
|
|
return {
|
|
version: '1',
|
|
title: 'BTCPay is connected',
|
|
message:
|
|
`Store id: ${body.store_id}\n` +
|
|
`Webhook id: ${body.webhook_id ?? '(not registered — check BTCPay manually)'}\n` +
|
|
`Base URL: ${body.base_url}` +
|
|
walletNote,
|
|
result: null,
|
|
}
|
|
},
|
|
)
|