627 lines
26 KiB
JavaScript
627 lines
26 KiB
JavaScript
// 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>`;
|
|
}
|