182 lines
6.5 KiB
TypeScript
182 lines
6.5 KiB
TypeScript
// Action: "Buy license" — starts a purchase against the issuing licensing
|
|
// server and returns a checkout URL. The operator opens the URL, pays with
|
|
// Bitcoin on the seller's BTCPay, and the license key is automatically
|
|
// captured by the companion action "Finish license purchase" — no copy/paste.
|
|
//
|
|
// Flow:
|
|
// 1. Buyer clicks "Buy license" → this action calls POST /v1/purchase on
|
|
// the seller's licensing server and returns the checkout URL.
|
|
// 2. Buyer opens that URL, pays the invoice.
|
|
// 3. Buyer clicks "Finish license purchase" → pollPurchase() → key arrives,
|
|
// we verify it offline, and we persist it. No typing, no copy/paste.
|
|
|
|
import { sdk } from '../sdk'
|
|
import { Client, Verifier, PublicKey } from '@keysat/licensing-client'
|
|
import {
|
|
LICENSING_BASE_URL,
|
|
LICENSING_BASE_URL_TOR,
|
|
PRODUCT_SLUG,
|
|
ISSUER_PUBKEY_PEM,
|
|
PRODUCT_DISPLAY_NAME,
|
|
} from '../licensing/config'
|
|
|
|
// --- "Buy license" ---------------------------------------------------------
|
|
|
|
const buyInput = sdk.InputSpec.of({
|
|
buyer_email: {
|
|
type: 'text',
|
|
name: 'Email (optional)',
|
|
description:
|
|
'If you would like a receipt or recovery email from the seller, enter it here. Otherwise leave blank.',
|
|
required: false,
|
|
default: null,
|
|
},
|
|
code: {
|
|
type: 'text',
|
|
name: 'Discount / referral code (optional)',
|
|
description:
|
|
'If the seller gave you a code (e.g. "FOUNDERS50"), enter it here. ' +
|
|
'It will be applied to the BTCPay invoice. For free-license codes, ' +
|
|
'use the "Redeem free license" action instead.',
|
|
required: false,
|
|
default: null,
|
|
},
|
|
})
|
|
|
|
export const buyLicense = sdk.Action.withInput(
|
|
'buy-license',
|
|
async ({ effects }) => ({
|
|
name: 'Buy license',
|
|
description:
|
|
`Start a Bitcoin-paid purchase of a ${PRODUCT_DISPLAY_NAME} license. ` +
|
|
`You will get a checkout URL to open in your browser; after you pay, ` +
|
|
`run "Finish license purchase" to have the key automatically ` +
|
|
`captured — you never need to copy/paste anything.`,
|
|
warning: null,
|
|
allowedStatuses: 'any',
|
|
group: 'License',
|
|
visibility: 'enabled',
|
|
}),
|
|
buyInput,
|
|
async ({ effects, input: form }) => {
|
|
const client = await firstReachableClient()
|
|
// Tell BTCPay to land the buyer on the licensing server's "thank you"
|
|
// page after a successful payment. That page reminds the buyer to
|
|
// return to their StartOS dashboard and run "Finish license purchase".
|
|
// We use the same base URL the client just probed as reachable so it
|
|
// matches the network path the buyer's browser can actually open
|
|
// (clearnet vs Tor).
|
|
const redirectUrl = `${client.baseUrl()}/thank-you`
|
|
const session = await client.startPurchase(PRODUCT_SLUG, {
|
|
buyerEmail: form.buyer_email ?? undefined,
|
|
code: form.code ?? undefined,
|
|
redirectUrl,
|
|
})
|
|
|
|
await sdk.store.getOwn(effects, sdk.StorePath).merge({
|
|
license_pending_invoice_id: session.invoiceId,
|
|
})
|
|
|
|
const codeApplied = form.code
|
|
? `\nCode applied: ${form.code} (final amount above reflects the discount)`
|
|
: ''
|
|
return {
|
|
message:
|
|
`Open this URL in your browser to pay:\n\n${session.checkoutUrl}\n\n` +
|
|
`Amount: ${session.amountSats} satoshis${codeApplied}\n` +
|
|
`Invoice id: ${session.invoiceId}\n\n` +
|
|
`After payment confirms on-chain or on Lightning, run the ` +
|
|
`"Finish license purchase" action — we will fetch the key for you.`,
|
|
}
|
|
},
|
|
)
|
|
|
|
// --- "Finish license purchase" --------------------------------------------
|
|
|
|
export const finishLicensePurchase = sdk.Action.withoutInput(
|
|
'finish-license-purchase',
|
|
async ({ effects }) => ({
|
|
name: 'Finish license purchase',
|
|
description:
|
|
`Check on a pending purchase started by "Buy license". If payment has ` +
|
|
`settled, the license key is fetched, verified, and stored ` +
|
|
`automatically.`,
|
|
warning: null,
|
|
allowedStatuses: 'any',
|
|
group: 'License',
|
|
visibility: 'enabled',
|
|
}),
|
|
async ({ effects }) => {
|
|
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
|
const invoiceId = (store as { license_pending_invoice_id: string | null })
|
|
.license_pending_invoice_id
|
|
if (!invoiceId) {
|
|
throw new Error(
|
|
`No pending purchase found. Run "Buy license" first to start one.`,
|
|
)
|
|
}
|
|
|
|
const client = await firstReachableClient()
|
|
const poll = await client.pollPurchase(invoiceId)
|
|
|
|
if (poll.status === 'expired' || poll.status === 'invalid') {
|
|
await sdk.store.getOwn(effects, sdk.StorePath).merge({
|
|
license_pending_invoice_id: null,
|
|
})
|
|
throw new Error(
|
|
`This invoice ${poll.status}. Run "Buy license" again to start over.`,
|
|
)
|
|
}
|
|
|
|
if (!poll.licenseKey) {
|
|
return {
|
|
message:
|
|
`Payment has not settled yet. Current status: ${poll.status}. ` +
|
|
`Try again in a minute — Bitcoin confirmations can take a few ` +
|
|
`minutes, Lightning is near-instant.`,
|
|
}
|
|
}
|
|
|
|
// Paid and key issued — verify and store.
|
|
const verifier = new Verifier(PublicKey.fromPem(ISSUER_PUBKEY_PEM))
|
|
const ok = verifier.verify(poll.licenseKey)
|
|
|
|
await sdk.store.getOwn(effects, sdk.StorePath).merge({
|
|
license_key: poll.licenseKey,
|
|
license_activated_at: new Date().toISOString(),
|
|
license_last_status: 'valid (issued from purchase)',
|
|
license_pending_invoice_id: null,
|
|
})
|
|
|
|
return {
|
|
message:
|
|
`Payment settled — license captured and activated.\n\n` +
|
|
`Product: ${ok.productId}\n` +
|
|
`License id: ${ok.licenseId}\n\n` +
|
|
`You may need to restart ${PRODUCT_DISPLAY_NAME} for the change to ` +
|
|
`take effect. Keep your Start9 backup up to date — that is where your ` +
|
|
`license now lives.`,
|
|
}
|
|
},
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Return a Client pinned to the first reachable base URL, or throw. */
|
|
async function firstReachableClient(): Promise<Client> {
|
|
const urls = [LICENSING_BASE_URL, LICENSING_BASE_URL_TOR].filter(Boolean) as string[]
|
|
for (const base of urls) {
|
|
const client = new Client(base)
|
|
try {
|
|
// /v1/pubkey is the cheapest health-check that still proves the service
|
|
// is responsive and talking to us.
|
|
await client.fetchPubkeyPem()
|
|
return client
|
|
} catch (_) {}
|
|
}
|
|
throw new Error(
|
|
`Could not reach the licensing server at any configured URL. ` +
|
|
`Check your network (and Tor, if applicable) and try again.`,
|
|
)
|
|
}
|