Add self-serve billing: tiers, credits, BTCPay and Zaprite

This commit is contained in:
Keysat
2026-06-13 13:36:05 -05:00
parent 84d56c94c9
commit 0aa648706e
17 changed files with 3781 additions and 116 deletions
+626
View File
@@ -0,0 +1,626 @@
// 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=<our callback> (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", `
<p>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.</p>
<p><a href="/dashboard.html">Back to dashboard</a></p>
`));
}
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", `
<p>No BTCPay URL is on file — re-start the setup from the
dashboard.</p>
<p><a href="/dashboard.html">Back to dashboard</a></p>
`));
}
if (
!providedState ||
providedState.length !== expectedState.length ||
!crypto.timingSafeEqual(
Buffer.from(providedState),
Buffer.from(expectedState)
)
) {
return res.status(403).send(html("Bad state token", `
<p>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.</p>
<p><a href="/dashboard.html">Back to dashboard</a></p>
`));
}
// 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", `
<h1 style="color:#86efac;">✓ BTCPay connected successfully.</h1>
<p>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.</p>
`));
}
// 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", `
<h1>Pick your Recap store</h1>
<p>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.</p>
<div id="status">Loading stores…</div>
<div id="stores"></div>
<script>
(async () => {
const r = await fetch("/admin/btcpay/stores");
const data = await r.json().catch(() => ({}));
if (!r.ok || !Array.isArray(data.stores)) {
document.getElementById("status").innerText =
"Couldn't list stores: " + (data.message || data.error || r.status);
return;
}
const list = document.getElementById("stores");
data.stores.forEach((s) => {
const btn = document.createElement("button");
btn.textContent = s.name + " (" + s.id.slice(0, 8) + "…)";
btn.style.cssText =
"display:block;margin:8px 0;padding:10px 14px;background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:8px;cursor:pointer;font-size:13px;width:100%;text-align:left;";
btn.onclick = async () => {
btn.disabled = true;
btn.textContent = "Setting up webhook…";
const r2 = await fetch("/admin/btcpay/finalize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ store_id: s.id }),
});
const data2 = await r2.json().catch(() => ({}));
if (!r2.ok) {
document.getElementById("status").innerHTML =
'<span style="color:#fca5a5;">Setup failed: ' +
(data2.message || data2.error || r2.status) + '</span>';
btn.disabled = false;
btn.textContent = s.name + " (" + s.id.slice(0, 8) + "…)";
return;
}
document.getElementById("status").innerHTML =
'<span style="color:#86efac;">✓ Connected to ' +
s.name + ' and webhook auto-created. You can close this tab.</span>';
document.getElementById("stores").style.display = "none";
};
list.appendChild(btn);
});
document.getElementById("status").innerText = "Pick a store:";
})();
</script>
`));
}
);
// 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 `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<title>${title}</title>
<style>
body { background:#0f172a; color:#e2e8f0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; margin:0; padding:40px; }
.wrap { max-width:560px; margin:0 auto; }
h1 { font-size:22px; margin:0 0 16px; }
a { color:#a5b4fc; }
button { font-family:inherit; }
</style></head><body><div class="wrap">${body}</div></body></html>`;
}