// 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 { 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.`, ) }