// One-click BTCPay setup. Three admin endpoints + a tiny standalone // HTML page that drives the flow: // // POST /admin/btcpay/start — generates an authorize URL // the operator opens in a // browser, with all the // right scopes pre-checked. // Body: { btcpay_url } // Returns: { authorize_url } // // POST /admin/btcpay/callback — receives the API key from // BTCPay after the operator // approves. Body comes from // BTCPay's POST redirect. // Stores key in config, returns // { stores: [...] } so the // operator can pick which // store to use. // // POST /admin/btcpay/finalize — sets the chosen store, then // creates the webhook // automatically. Body: // { store_id, public_relay_url } // Stores webhook secret in // config too. Returns // { ok: true, webhook_url }. // // This replaces the 4-step manual setup (generate API key in BTCPay, // generate webhook + copy secret, copy store ID, paste all four into // Set BTCPay Connection action) with: paste BTCPay URL → click // approve → pick store → done. import express from "express"; import crypto from "crypto"; import fs from "fs/promises"; import path from "path"; import { getConfigSnapshot } from "../config.js"; import { rescanSettledInvoices } from "./credits.js"; export function btcpaySetupRouter({ dataDir }) { const router = express.Router(); // Step 1: build the authorize URL. The operator hits this from the // setup page (or by calling it directly via curl) with their // BTCPay base URL, and we return the URL they should open in a // browser to grant Recap Relay the permissions it needs. // // BTCPay's API Key Authorization Flow: // GET /api-keys/authorize // ?permissions=btcpay.store.cancreateinvoice // &permissions=btcpay.store.canviewinvoices // &permissions=btcpay.store.webhooks.canmodifywebhooks // &applicationName=Recap+Relay // &applicationIdentifier=recap-relay // &strict=true — buyer can't tweak the scopes we asked // &selectiveStores=true — let buyer pick the target store // &redirect= (POST redirect, not GET) router.post("/start", express.json(), async (req, res) => { const btcpayUrl = (req.body?.btcpay_url || "").trim(); if (!btcpayUrl || !/^https?:\/\//i.test(btcpayUrl)) { return res.status(400).json({ error: "btcpay_url_required", message: "Provide btcpay_url (e.g. https://btcpay.keysat.xyz).", }); } // If BTCPay is co-installed on the same Start9 box, the setup // discovery file gives us the internal hostname (resolvable from // inside this container) — needed because the browser-facing // URL (mDNS .local or clearnet) often isn't resolvable from // inside the docker network. We persist BOTH so the callback + // finalize phases know which URL to use for server-to-server // BTCPay API calls. const internalUrl = await readDiscoveredInternalUrl(dataDir); const publicUrl = await readDiscoveredPublicUrl(dataDir); const state = crypto.randomBytes(24).toString("hex"); const baseRelayUrl = absoluteRelayUrl(req); const callbackUrl = `${baseRelayUrl}/admin/btcpay/callback?state=${state}`; // Permissions mirror Keysat's working set. Specifically: // - canviewstoresettings: required to list stores (we call // /api/v1/stores in the picker page) // - canmodifystoresettings: required to register the webhook // (BTCPay treats webhook management as a store setting, not // a separate scope) // - canviewinvoices / cancreateinvoice / canmodifyinvoices: // the actual credit-purchase flow const params = new URLSearchParams(); params.append("permissions", "btcpay.store.canviewstoresettings"); params.append("permissions", "btcpay.store.canmodifystoresettings"); params.append("permissions", "btcpay.store.canviewinvoices"); params.append("permissions", "btcpay.store.cancreateinvoice"); params.append("permissions", "btcpay.store.canmodifyinvoices"); params.append("applicationName", "Recap Relay"); params.append("applicationIdentifier", "recap-relay"); params.append("strict", "true"); params.append("selectiveStores", "true"); params.append("redirect", callbackUrl); const authorizeUrl = btcpayUrl.replace(/\/$/, "") + "/api-keys/authorize?" + params.toString(); await stashSetupContext(dataDir, { btcpay_url: btcpayUrl, btcpay_internal_url: internalUrl, btcpay_public_url: publicUrl, state, }); res.json({ authorize_url: authorizeUrl, callback_url: callbackUrl }); }); // Step 2: BTCPay POST-redirects the operator's browser back here // with the API key + permissions in the body. Body shape: // { apiKey: "...", permissions: ["btcpay.store.X:STOREID", ...] } // // We persist the key into config, look up the store list, and // return it so the operator can pick which store to use. router.post( "/callback", express.urlencoded({ extended: true }), express.json(), async (req, res) => { const body = req.body || {}; const apiKey = body.apiKey || body.api_key || (typeof body === "string" ? body : null); const permissions = Array.isArray(body.permissions) ? body.permissions : []; if (!apiKey || typeof apiKey !== "string") { // Show a plain HTML response since this is a browser redirect. return res.status(400).send(html("Authorization failed", `

BTCPay didn't return an API key — likely you clicked Deny, or your BTCPay version doesn't support the authorization flow. You can still set up manually via the StartOS action.

Back to dashboard

`)); } const ctx = await readSetupContext(dataDir); const btcpayUrl = ctx?.btcpay_url; const expectedState = ctx?.state; const providedState = (req.query?.state || "").toString(); if (!btcpayUrl || !expectedState) { return res.status(400).send(html("Setup context lost", `

No BTCPay URL is on file — re-start the setup from the dashboard.

Back to dashboard

`)); } if ( !providedState || providedState.length !== expectedState.length || !crypto.timingSafeEqual( Buffer.from(providedState), Buffer.from(expectedState) ) ) { return res.status(403).send(html("Bad state token", `

The authorization callback didn't carry a matching state token. This usually means someone else's setup is in flight, or the link was tampered with. Re-start setup from the dashboard.

Back to dashboard

`)); } // Preserve the fields /start stashed (especially // btcpay_internal_url), don't overwrite the whole object. // Without the merge, the picker page's /stores call falls back // to btcpay_url (mDNS) which can't resolve inside the docker // container → "getaddrinfo ENOTFOUND". const mergedCtx = { ...(ctx || {}), btcpay_url: btcpayUrl, api_key: apiKey, permissions, }; await stashSetupContext(dataDir, mergedCtx); // ── Auto-finalize when the operator authorized for exactly // one store ── // BTCPay's authorize page already has a built-in store picker // when we pass selectiveStores=true. The granted permissions // encode the chosen store ID(s) in `btcpay.store.X:STOREID` // suffixes. When only one store was selected, our second // picker is redundant — skip straight to finalize. const storeIds = extractStoreIdsFromPermissions(permissions); if (storeIds.length === 1) { const baseRelayUrl = absoluteRelayUrl(req); const result = await finalizeBtcpay({ ctx: mergedCtx, storeId: storeIds[0], dataDir, baseRelayUrl, }); if (result.ok) { return res.send(html("Connected", `

✓ BTCPay connected successfully.

You can close this tab and return to the Recap Relay dashboard. The webhook is wired up automatically and users can now top up their credit balance via Lightning.

`)); } // Auto-finalize failed (e.g. webhook creation hiccup). Fall // through to the manual picker so the operator can retry. console.warn( `[btcpay/callback] auto-finalize failed for sole store ${storeIds[0]}: ${result.error} ${result.message?.slice(0, 200)}` ); } // Hand the operator a tiny picker page that calls back into // /admin/btcpay/finalize once they pick. return res.send(html("Pick your store", `

Pick your Recap store

Authorization successful. One last step — pick which BTCPay store invoices should be created against, and the relay will finish wiring everything up (including the webhook) for you.

Loading stores…
`)); } ); // Lists stores reachable with the API key from setup context. The // store-picker page above calls this. router.get("/stores", async (_req, res) => { const ctx = await readSetupContext(dataDir); if (!ctx?.btcpay_url || !ctx?.api_key) { console.warn("[btcpay/stores] setup_context_missing — operator may need to restart connect flow"); return res .status(400) .json({ error: "setup_context_missing" }); } // Server-to-server: prefer internal hostname when discovered // (mDNS .local doesn't resolve inside docker; clearnet works but // takes the long way round). const apiBase = (ctx.btcpay_internal_url || ctx.btcpay_url).replace(/\/$/, ""); const url = `${apiBase}/api/v1/stores`; console.log(`[btcpay/stores] GET ${url}`); try { const r = await fetch(url, { headers: { Authorization: `token ${ctx.api_key}` }, }); console.log(`[btcpay/stores] BTCPay responded ${r.status}`); if (!r.ok) { const text = await r.text(); console.warn(`[btcpay/stores] non-ok body: ${text.slice(0, 200)}`); return res.status(502).json({ error: "btcpay_stores_failed", message: text.slice(0, 200), }); } const stores = await r.json(); console.log(`[btcpay/stores] returned ${Array.isArray(stores) ? stores.length : "?"} stores`); res.json({ stores }); } catch (err) { const msg = err?.message || String(err); const cause = err?.cause?.message || err?.cause?.code || err?.cause; console.error( `[btcpay/stores] fetch threw: ${msg}${cause ? ` (cause: ${cause})` : ""} url=${url}` ); res.status(502).json({ error: "btcpay_stores_failed", message: msg.slice(0, 200) + (cause ? ` (cause: ${String(cause).slice(0, 120)})` : ""), }); } }); // Step 3: finalize. Pin the chosen store, create a webhook // pointing back at /relay/btcpay/webhook, and write all four // BTCPay fields to the live config. router.post("/finalize", express.json(), async (req, res) => { const storeId = (req.body?.store_id || "").trim(); if (!storeId) { return res.status(400).json({ error: "store_id_required" }); } const ctx = await readSetupContext(dataDir); if (!ctx?.btcpay_url || !ctx?.api_key) { return res.status(400).json({ error: "setup_context_missing" }); } const baseRelayUrl = absoluteRelayUrl(req); const result = await finalizeBtcpay({ ctx, storeId, dataDir, baseRelayUrl }); if (!result.ok) { return res.status(result.status).json({ error: result.error, message: result.message, }); } res.json({ ok: true, webhook_url: result.webhookUrl }); }); // Auto-discovery: does the StartOS init phase know about a local // BTCPay install? Reads the file written by startos/init/setup.ts // via sdk.serviceInterface.getAll. When present, the dashboard // skips the URL-prompt step and goes straight to /start with the // discovered URL. router.get("/discover", async (_req, res) => { const discoveryPath = path.join(dataDir, "discovered-services.json"); try { const raw = await fs.readFile(discoveryPath, "utf8"); const parsed = JSON.parse(raw); if (parsed?.btcpay?.browser_url) { return res.json({ found: true, browser_url: parsed.btcpay.browser_url, discovered_at: parsed.btcpay.discovered_at, }); } return res.json({ found: false }); } catch (err) { // No discovery file yet (fresh install hadn't run setup, or // BTCPay wasn't installed when setup ran). Treat as not found. return res.json({ found: false }); } }); // Operator-initiated recovery: scan recent settled BTCPay invoices // and grant credits for any that weren't processed via the webhook // (typical cause: broken webhook URL pointing at unreachable // mDNS). Idempotent. Returns a summary of what got credited. router.post("/rescan-invoices", async (_req, res) => { try { const result = await rescanSettledInvoices(); if (!result.ok) { return res.status(502).json(result); } res.json(result); } catch (err) { res.status(500).json({ error: "rescan_failed", message: (err?.message || String(err)).slice(0, 200), }); } }); // Convenience: tells the dashboard whether BTCPay is fully wired // up, half-set-up, or empty — so the dashboard can show the right // CTA ("Connect BTCPay" / "Reconnect" / "Connected"). router.get("/status", async (_req, res) => { const cfg = await getConfigSnapshot(); res.json({ configured: !!( cfg.relay_btcpay_base_url && cfg.relay_btcpay_store_id && cfg.relay_btcpay_api_key && cfg.relay_btcpay_webhook_secret ), base_url: cfg.relay_btcpay_base_url || null, store_id: cfg.relay_btcpay_store_id || null, has_api_key: !!cfg.relay_btcpay_api_key, has_webhook_secret: !!cfg.relay_btcpay_webhook_secret, }); }); return router; } // Setup-context is a transient JSON blob holding partial setup state // (BTCPay URL + API key + permissions) between the authorize callback // and the finalize step. Lives on disk only while a setup is in // progress; finalize wipes it. function setupContextPath(dataDir) { return path.join(dataDir, "btcpay-setup.json"); } async function stashSetupContext(dataDir, ctx) { const file = setupContextPath(dataDir); await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, JSON.stringify(ctx), { mode: 0o600 }); } async function readSetupContext(dataDir) { try { const raw = await fs.readFile(setupContextPath(dataDir), "utf8"); return JSON.parse(raw); } catch { return null; } } async function clearSetupContext(dataDir) { try { await fs.unlink(setupContextPath(dataDir)); } catch {} } // Extract the set of unique store IDs the operator selected on // BTCPay's authorize page. With `selectiveStores=true`, every granted // permission is suffixed with `:STOREID` — same store ID on every // entry when the operator picked just one. Returns an array of // unique store ids (possibly empty if BTCPay didn't supply them). function extractStoreIdsFromPermissions(permissions) { if (!Array.isArray(permissions)) return []; const ids = new Set(); for (const p of permissions) { if (typeof p !== "string") continue; const m = p.match(/^btcpay\.store\.[^:]+:(\S+)$/); if (m && m[1]) ids.add(m[1]); } return Array.from(ids); } // Shared finalize: create the webhook on BTCPay, write the four // BTCPay credentials into the live relay config, clear the setup // context. Used by the explicit POST /finalize endpoint AND by the // callback's auto-finalize path when only one store was authorized. // // Webhook URL needs to be a hostname BTCPay's container can reach // over the network. Priority: // 1. Relay's discovered clearnet URL (e.g. https://relay.keysat.xyz) // — works because BTCPay container has standard internet DNS. // 2. baseRelayUrl (the Host header from the operator's browser request) // — fallback for operators with no clearnet URL set up; only works // if BTCPay happens to be able to resolve that name. // We never want to use mDNS .local here — BTCPay's container has no // avahi so .local won't resolve and every webhook delivery fails // silently, which is exactly the bug that caused paid invoices to // not credit the install. // // Before creating the new webhook we GET + DELETE any existing // webhooks on this store pointing at /relay/btcpay/webhook — keeps // the dashboard's Reconnect button idempotent and prevents BTCPay // from accumulating dead webhook entries that retry forever. async function finalizeBtcpay({ ctx, storeId, dataDir, baseRelayUrl }) { const selfPublicUrl = await readDiscoveredSelfPublicUrl(dataDir); const webhookBase = (selfPublicUrl || baseRelayUrl).replace(/\/$/, ""); const webhookUrl = `${webhookBase}/relay/btcpay/webhook`; const webhookSecret = crypto.randomBytes(32).toString("hex"); const apiBase = (ctx.btcpay_internal_url || ctx.btcpay_url).replace(/\/$/, ""); const authHeader = `token ${ctx.api_key}`; // Delete any existing webhooks on this store that point at our // webhook path. Otherwise Reconnect leaves the old (possibly // broken) webhook in place AND adds a new one, and BTCPay sends // each InvoiceSettled to both. try { const listRes = await fetch( `${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks`, { headers: { Authorization: authHeader } } ); if (listRes.ok) { const existing = await listRes.json(); if (Array.isArray(existing)) { for (const w of existing) { if (typeof w?.url === "string" && /\/relay\/btcpay\/webhook$/i.test(w.url)) { try { await fetch( `${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks/${encodeURIComponent(w.id)}`, { method: "DELETE", headers: { Authorization: authHeader } } ); console.log(`[btcpay/finalize] deleted stale webhook ${w.id} (url=${w.url})`); } catch (err) { console.warn(`[btcpay/finalize] failed to delete stale webhook ${w.id}: ${err?.message || err}`); } } } } } } catch (err) { console.warn(`[btcpay/finalize] webhook cleanup skipped: ${err?.message || err}`); } try { const r = await fetch( `${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks`, { method: "POST", headers: { Authorization: authHeader, "Content-Type": "application/json", }, body: JSON.stringify({ url: webhookUrl, secret: webhookSecret, authorizedEvents: { everything: false, specificEvents: ["InvoiceSettled"] }, automaticRedelivery: true, enabled: true, }), } ); if (!r.ok) { const text = await r.text(); return { ok: false, status: 502, error: "webhook_create_failed", message: text.slice(0, 300), }; } console.log(`[btcpay/finalize] webhook created: ${webhookUrl}`); } catch (err) { return { ok: false, status: 502, error: "webhook_create_failed", message: (err?.message || String(err)).slice(0, 200), }; } const configPath = path.join(dataDir, "config", "relay-config.json"); let existing = {}; try { existing = JSON.parse(await fs.readFile(configPath, "utf8")); } catch {} existing.relay_btcpay_base_url = ctx.btcpay_url; existing.relay_btcpay_internal_url = ctx.btcpay_internal_url || ""; existing.relay_btcpay_public_url = ctx.btcpay_public_url || ""; existing.relay_btcpay_store_id = storeId; existing.relay_btcpay_api_key = ctx.api_key; existing.relay_btcpay_webhook_secret = webhookSecret; await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, JSON.stringify(existing), { mode: 0o600 }); await clearSetupContext(dataDir); return { ok: true, webhookUrl }; } // Pull btcpay.internal_url from the discovered-services.json file // written by the StartOS setup hook (init/setup.ts). Returns null if // BTCPay isn't co-installed or discovery hasn't run. async function readDiscoveredInternalUrl(dataDir) { try { const raw = await fs.readFile( path.join(dataDir, "discovered-services.json"), "utf8" ); const parsed = JSON.parse(raw); return parsed?.btcpay?.internal_url || null; } catch { return null; } } // Companion to readDiscoveredInternalUrl: returns the public clearnet // URL Spark Control discovered for BTCPay. Used to rewrite the // buyer-facing checkout link. async function readDiscoveredPublicUrl(dataDir) { try { const raw = await fs.readFile( path.join(dataDir, "discovered-services.json"), "utf8" ); const parsed = JSON.parse(raw); return parsed?.btcpay?.public_url || null; } catch { return null; } } // The relay's OWN clearnet URL (e.g. https://relay.keysat.xyz) — // captured at install time via sdk.serviceInterface.getAllOwn. // Used to construct the webhook URL we register with BTCPay so // BTCPay's container can actually reach it. async function readDiscoveredSelfPublicUrl(dataDir) { try { const raw = await fs.readFile( path.join(dataDir, "discovered-services.json"), "utf8" ); const parsed = JSON.parse(raw); return parsed?.self?.public_url || null; } catch { return null; } } // Reverse-engineer the public-facing URL of the relay from the // incoming request. Used to build the callback + webhook URLs without // having to ask the operator to type their own hostname in twice. function absoluteRelayUrl(req) { const proto = (req.headers["x-forwarded-proto"] || "").split(",")[0].trim() || req.protocol || "http"; const host = req.headers["x-forwarded-host"] || req.headers.host; return `${proto}://${host}`; } // Minimal HTML wrapper for the picker page. Matches the dashboard's // dark palette so the operator doesn't feel like they've fallen off // the edge of the world. function html(title, body) { return ` ${title}
${body}
`; }