Add self-serve billing: tiers, credits, BTCPay and Zaprite
This commit is contained in:
@@ -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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user