Add self-serve billing: tiers, credits, BTCPay and Zaprite
This commit is contained in:
+43
-26
@@ -1,47 +1,64 @@
|
||||
// GET /relay/balance — peek at the current install's credit balance +
|
||||
// tier WITHOUT charging anything. Recap clients call this to populate
|
||||
// the "N credits remaining · Tier: X" banner before the user runs any
|
||||
// transcribe/analyze, so the display is accurate on first load instead
|
||||
// of saying "balance unknown — no relay calls yet".
|
||||
// GET /relay/balance — peek at the caller's credit balance + tier WITHOUT
|
||||
// charging anything. Recap clients call this to populate the
|
||||
// "N credits remaining · Tier: X" banner.
|
||||
//
|
||||
// Same auth surface as the metered endpoints:
|
||||
// X-Recap-Install-Id (required)
|
||||
// Authorization (optional Bearer LIC1-... — absent = Core)
|
||||
// Two auth surfaces (see identity.js):
|
||||
// - cloud: X-Recap-User-Id + X-Recap-Operator-Key → user:<id> pool,
|
||||
// tier is the relay's stored (operator-set) value.
|
||||
// - license: X-Recap-Install-Id (+ optional Bearer license) → legacy
|
||||
// license/install pool, tier from the license.
|
||||
//
|
||||
// Returns the standard envelope shape with result=null and
|
||||
// credit_charged=0. The license-resolution path is identical to
|
||||
// /relay/transcribe and /relay/analyze, so the cached online check
|
||||
// against keysat happens here too — but no row mutation, no job-id
|
||||
// reservation, no upstream backend call.
|
||||
// Returns the standard envelope (result=null, credit_charged=0). No row
|
||||
// mutation beyond keeping tier_snapshot synced on the license path.
|
||||
|
||||
import express from "express";
|
||||
import { resolveLicense } from "../keysat-client.js";
|
||||
import { getOrCreateRow } from "../credits.js";
|
||||
import { resolveIdentity, identityTier } from "../identity.js";
|
||||
import { getOrCreateRow, applyTierPromotion } from "../credits.js";
|
||||
import { envelope, errorEnvelope } from "./envelope.js";
|
||||
|
||||
export function balanceRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/balance", async (req, res) => {
|
||||
const installId = req.header("X-Recap-Install-Id");
|
||||
const auth = req.header("Authorization");
|
||||
if (!installId) {
|
||||
let identity;
|
||||
try {
|
||||
identity = await resolveIdentity(req);
|
||||
} catch (err) {
|
||||
const e = await errorEnvelope({
|
||||
error: err?.message || "auth_error",
|
||||
statusHint: err?.status || 401,
|
||||
});
|
||||
return res.status(e.statusHint || 401).json(e.body);
|
||||
}
|
||||
if (identity.kind === "license" && !identity.installId) {
|
||||
const e = await errorEnvelope({
|
||||
error: "missing X-Recap-Install-Id header",
|
||||
statusHint: 400,
|
||||
});
|
||||
return res.status(400).json(e.body);
|
||||
}
|
||||
const license = await resolveLicense(auth);
|
||||
const tier = license.tier;
|
||||
// Touch the row so tier_snapshot reflects the most recently seen
|
||||
// license tier — same as the metered endpoints do — but commit
|
||||
// nothing.
|
||||
const row = await getOrCreateRow(installId);
|
||||
row.tier_snapshot = tier;
|
||||
|
||||
const row = await getOrCreateRow({
|
||||
creditKey: identity.creditKey,
|
||||
installId: identity.installId,
|
||||
license: identity.license,
|
||||
});
|
||||
// License path: fire the Core→paid promotion bookkeeping (this is
|
||||
// typically the FIRST relay call after a license activation) and keep
|
||||
// tier_snapshot synced to the license. Cloud path: the tier is the
|
||||
// relay's stored, operator-set value — leave it untouched.
|
||||
if (identity.kind === "license") {
|
||||
const tier = identity.license.tier;
|
||||
const promoted = await applyTierPromotion(row, tier);
|
||||
if (!promoted) row.tier_snapshot = tier;
|
||||
}
|
||||
|
||||
const tier = identityTier(identity, row);
|
||||
const body = await envelope({
|
||||
result: null,
|
||||
installId,
|
||||
creditKey: identity.creditKey,
|
||||
installId: identity.installId,
|
||||
license: identity.license,
|
||||
tier,
|
||||
creditCharged: 0,
|
||||
});
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
// /relay/credits/* — buy + poll relay credit top-ups via BTCPay.
|
||||
//
|
||||
// Three endpoints:
|
||||
//
|
||||
// GET /relay/credits/packages (no auth) — operator's
|
||||
// configured pricing menu.
|
||||
// The Recap UI fetches this
|
||||
// before showing the modal so
|
||||
// it always reflects current
|
||||
// pricing.
|
||||
//
|
||||
// POST /relay/credits/buy (X-Recap-Install-Id required)
|
||||
// Body: { credits: number }
|
||||
// Mints a BTCPay invoice tied
|
||||
// to this install + the chosen
|
||||
// credit pack. Returns
|
||||
// { invoice_id, checkout_url,
|
||||
// sats, credits, expires_at }.
|
||||
//
|
||||
// GET /relay/credits/invoice/:id (X-Recap-Install-Id required)
|
||||
// Polls BTCPay for invoice
|
||||
// status. Returns
|
||||
// { status: "new"|"processing"|
|
||||
// "settled"|"expired"|"invalid",
|
||||
// credits_remaining }.
|
||||
//
|
||||
// POST /relay/btcpay/webhook (no auth — HMAC validated)
|
||||
// BTCPay calls this on payment
|
||||
// events. We dedupe by
|
||||
// invoice_id and only credit
|
||||
// the install once per invoice.
|
||||
|
||||
import express from "express";
|
||||
import { resolveLicense } from "../keysat-client.js";
|
||||
import {
|
||||
addPurchasedCredits,
|
||||
getOrCreateRow,
|
||||
computeRemaining,
|
||||
licenseFingerprint,
|
||||
extendUserTier,
|
||||
} from "../credits.js";
|
||||
import { getConfigSnapshot, getTierQuotas, getCreditPackages } from "../config.js";
|
||||
import {
|
||||
createInvoice,
|
||||
getInvoice,
|
||||
getInvoicePaymentMethods,
|
||||
pickLightningFromPaymentMethods,
|
||||
validateWebhookSignature,
|
||||
BtcPayError,
|
||||
} from "../btcpay-client.js";
|
||||
import { recordCall } from "../audit-log.js";
|
||||
import { envelope, errorEnvelope } from "./envelope.js";
|
||||
|
||||
// In-memory dedup of processed BTCPay invoice ids. BTCPay retries
|
||||
// webhook deliveries on non-2xx responses or network errors, so the
|
||||
// same InvoiceSettled event can land more than once. We don't want
|
||||
// to grant credits twice for one paid invoice.
|
||||
//
|
||||
// Cleared on relay restart, which means an unlucky webhook duplicate
|
||||
// straddling a restart would double-credit. Acceptable tradeoff for
|
||||
// v1 (operator can manually adjust the ledger if it happens), but
|
||||
// worth swapping for a persistent set once the relay sees real load.
|
||||
const processedInvoices = new Set();
|
||||
|
||||
export function creditsRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// ── GET /relay/credits/packages ──────────────────────────────────────
|
||||
router.get("/credits/packages", async (_req, res) => {
|
||||
const packages = await getCreditPackages();
|
||||
res.json({ packages });
|
||||
});
|
||||
|
||||
// ── POST /relay/credits/buy ──────────────────────────────────────────
|
||||
router.post("/credits/buy", express.json(), async (req, res) => {
|
||||
const installId = req.header("X-Recap-Install-Id");
|
||||
const auth = req.header("Authorization");
|
||||
if (!installId) {
|
||||
const e = await errorEnvelope({
|
||||
error: "missing X-Recap-Install-Id header",
|
||||
statusHint: 400,
|
||||
});
|
||||
return res.status(400).json(e.body);
|
||||
}
|
||||
const requestedCredits = Number(req.body?.credits);
|
||||
// Optional: where to send the buyer's browser after they pay.
|
||||
// Frontend passes its own origin/page so the buyer lands back on
|
||||
// Recap instead of stuck on BTCPay's "Paid ✓" screen. We trust
|
||||
// the value blindly because Recap is the one calling us — but
|
||||
// even so we only forward it to BTCPay's checkout, never use it
|
||||
// in any sensitive context.
|
||||
const returnUrl =
|
||||
typeof req.body?.return_url === "string" && req.body.return_url.startsWith("http")
|
||||
? req.body.return_url
|
||||
: null;
|
||||
if (!Number.isFinite(requestedCredits) || requestedCredits <= 0) {
|
||||
const e = await errorEnvelope({
|
||||
error: "credits must be a positive number",
|
||||
installId,
|
||||
statusHint: 400,
|
||||
});
|
||||
return res.status(400).json(e.body);
|
||||
}
|
||||
|
||||
// Match against the configured package list — buyers can't
|
||||
// request arbitrary credit-for-sats ratios.
|
||||
const packages = await getCreditPackages();
|
||||
const pack = packages.find((p) => p.credits === requestedCredits);
|
||||
if (!pack) {
|
||||
const e = await errorEnvelope({
|
||||
error: `unknown package: ${requestedCredits} credits`,
|
||||
installId,
|
||||
statusHint: 400,
|
||||
});
|
||||
return res.status(400).json(e.body);
|
||||
}
|
||||
|
||||
const license = await resolveLicense(auth);
|
||||
const cfg = await getConfigSnapshot();
|
||||
if (
|
||||
!cfg.relay_btcpay_base_url ||
|
||||
!cfg.relay_btcpay_store_id ||
|
||||
!cfg.relay_btcpay_api_key
|
||||
) {
|
||||
const e = await errorEnvelope({
|
||||
error: "BTCPay is not configured on this relay — contact the operator",
|
||||
installId,
|
||||
tier: license.tier,
|
||||
statusHint: 503,
|
||||
});
|
||||
return res.status(503).json(e.body);
|
||||
}
|
||||
|
||||
try {
|
||||
const buyerFp = licenseFingerprint(license);
|
||||
const invoice = await createInvoice({
|
||||
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
|
||||
storeId: cfg.relay_btcpay_store_id,
|
||||
apiKey: cfg.relay_btcpay_api_key,
|
||||
sats: pack.sats,
|
||||
credits: pack.credits,
|
||||
installId,
|
||||
// Stash the buyer's license fingerprint (if any) on the
|
||||
// invoice so the webhook handler grants credits to their
|
||||
// license-keyed pool — survives restarts because BTCPay echoes
|
||||
// the metadata back on every event.
|
||||
licenseFingerprint: buyerFp,
|
||||
packageLabel: `${pack.credits} relay credits`,
|
||||
redirectURL: returnUrl || undefined,
|
||||
redirectAutomatically: !!returnUrl,
|
||||
});
|
||||
|
||||
await recordCall({
|
||||
install_id: installId,
|
||||
license_fingerprint: buyerFp,
|
||||
tier: license.tier,
|
||||
pipeline: "credits_purchase",
|
||||
backend: null,
|
||||
model: null,
|
||||
status: "invoice_created",
|
||||
credit_charged: 0,
|
||||
duration_ms: 0,
|
||||
cost_usd: 0,
|
||||
purchase_credits: pack.credits,
|
||||
purchase_sats: pack.sats,
|
||||
invoice_id: invoice.id,
|
||||
});
|
||||
|
||||
// Fetch the per-payment-method breakdown so we can surface
|
||||
// the raw BOLT11 invoice + Lightning deep-link to the Recap
|
||||
// UI. Recap renders an inline QR + "Open in wallet" using
|
||||
// these fields, removing the redirect-to-BTCPay-checkout
|
||||
// step entirely (Phase 1 of the inline-payment migration).
|
||||
//
|
||||
// Best-effort: BTCPay sometimes generates the Lightning
|
||||
// invoice asynchronously and the destination is empty on the
|
||||
// first hit. We retry once with a short backoff. If still
|
||||
// empty (LN not configured on the store, or slow LND), we
|
||||
// fall through with null — Recap then falls back to the
|
||||
// legacy "open checkout_url in another tab" path. Never
|
||||
// fails the purchase because of this.
|
||||
//
|
||||
// Diagnostic capture: when bolt11 ends up null we surface
|
||||
// enough info on the response (under `_debug`) to figure out
|
||||
// why without operators having to comb through logs. Will be
|
||||
// removed once Phase 1 is verified working everywhere.
|
||||
let bolt11 = null;
|
||||
let lightningPaymentLink = null;
|
||||
let lnDebug = null;
|
||||
try {
|
||||
let methods = await getInvoicePaymentMethods({
|
||||
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
|
||||
storeId: cfg.relay_btcpay_store_id,
|
||||
apiKey: cfg.relay_btcpay_api_key,
|
||||
invoiceId: invoice.id,
|
||||
});
|
||||
let ln = pickLightningFromPaymentMethods(methods);
|
||||
if (!ln) {
|
||||
// Retry once after a short backoff — LN invoice generation
|
||||
// is async on some BTCPay configurations (LND, c-lightning
|
||||
// over Tor, etc.).
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
methods = await getInvoicePaymentMethods({
|
||||
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
|
||||
storeId: cfg.relay_btcpay_store_id,
|
||||
apiKey: cfg.relay_btcpay_api_key,
|
||||
invoiceId: invoice.id,
|
||||
});
|
||||
ln = pickLightningFromPaymentMethods(methods);
|
||||
}
|
||||
if (ln) {
|
||||
bolt11 = ln.bolt11;
|
||||
lightningPaymentLink = ln.paymentLink;
|
||||
} else {
|
||||
// Dump exactly what BTCPay returned so we can fix the
|
||||
// pick-function heuristic for whatever version /
|
||||
// shape this store uses. Sample first ~3 methods,
|
||||
// exclude the per-method 'payments' history because
|
||||
// those can be large.
|
||||
const sample = (Array.isArray(methods) ? methods : [])
|
||||
.slice(0, 3)
|
||||
.map((m) => {
|
||||
const out = {};
|
||||
for (const k of Object.keys(m || {})) {
|
||||
if (k === "payments") continue;
|
||||
const v = m[k];
|
||||
if (v === null || ["string", "number", "boolean"].includes(typeof v)) {
|
||||
out[k] = v;
|
||||
} else {
|
||||
out[k] = `<${typeof v}>`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
lnDebug = {
|
||||
reason: "no_lightning_method",
|
||||
methods_count: Array.isArray(methods) ? methods.length : 0,
|
||||
sample,
|
||||
};
|
||||
console.warn(
|
||||
`[credits/buy] invoice ${invoice.id}: no Lightning method on payment-methods response — sample=${JSON.stringify(sample)}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
lnDebug = {
|
||||
reason: "fetch_failed",
|
||||
message: (err?.message || String(err)).slice(0, 300),
|
||||
status:
|
||||
err && typeof err === "object" && "status" in err
|
||||
? err.status
|
||||
: null,
|
||||
};
|
||||
console.warn(
|
||||
`[credits/buy] invoice ${invoice.id}: payment-methods fetch failed (${err?.message || err}) — Recap will fall back to checkout URL`
|
||||
);
|
||||
}
|
||||
|
||||
const rawCheckout =
|
||||
invoice.checkoutLink ||
|
||||
(invoice.checkout && invoice.checkout.redirectURL) ||
|
||||
null;
|
||||
const body = await envelope({
|
||||
result: {
|
||||
invoice_id: invoice.id,
|
||||
// BTCPay returns the checkout URL with the internal
|
||||
// hostname we called it on (`btcpayserver.startos:23000`).
|
||||
// Rewrite to the operator's browser-facing URL so the
|
||||
// buyer's browser can actually reach it.
|
||||
// Buyer-facing: prefer the clearnet public URL over the
|
||||
// LAN base URL so buyers from anywhere on the internet
|
||||
// can pay. Falls back to base URL when no clearnet is set.
|
||||
checkout_url: rewriteCheckoutUrl(
|
||||
rawCheckout,
|
||||
cfg.relay_btcpay_public_url || cfg.relay_btcpay_base_url
|
||||
),
|
||||
sats: pack.sats,
|
||||
credits: pack.credits,
|
||||
expires_at: invoice.expirationTime || null,
|
||||
status: invoice.status || "New",
|
||||
// Inline-payment fields (added in Phase 1). May be null
|
||||
// if LN isn't configured on the store, or if BTCPay
|
||||
// hasn't finished generating the LN invoice — Recap is
|
||||
// built to fall back to checkout_url in those cases.
|
||||
bolt11,
|
||||
lightning_payment_link: lightningPaymentLink,
|
||||
// Mirror BTCPay's invoice-level expiration as the
|
||||
// canonical "this invoice is no longer payable" deadline.
|
||||
// The Lightning invoice itself has its own expiry encoded
|
||||
// in the BOLT11, but BTCPay aligns the two by default and
|
||||
// surfacing the BTCPay number keeps things consistent
|
||||
// with the existing expires_at field above.
|
||||
lightning_expires_at: invoice.expirationTime || null,
|
||||
// Diagnostic — only set when bolt11 came back null. Lets
|
||||
// the Recap UI explain WHY the inline path didn't light
|
||||
// up without operators tailing relay logs. Will be
|
||||
// removed in a follow-up once Phase 1 is verified.
|
||||
_ln_debug: lnDebug,
|
||||
},
|
||||
installId,
|
||||
license,
|
||||
tier: license.tier,
|
||||
});
|
||||
res.json(body);
|
||||
} catch (err) {
|
||||
console.error(`[credits/buy] failed: ${err?.message || err}`);
|
||||
const status =
|
||||
err instanceof BtcPayError && err.status === 401 ? 502 : 502;
|
||||
const e = await errorEnvelope({
|
||||
error: err?.message || "btcpay_create_invoice_failed",
|
||||
installId,
|
||||
tier: license.tier,
|
||||
statusHint: status,
|
||||
});
|
||||
return res.status(status).json(e.body);
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /relay/credits/invoice/:id ───────────────────────────────────
|
||||
// Poll loop's friend. Returns the BTCPay status PLUS the install's
|
||||
// updated credit balance so the UI can refresh both in one round-
|
||||
// trip after settlement.
|
||||
router.get("/credits/invoice/:id", async (req, res) => {
|
||||
const installId = req.header("X-Recap-Install-Id");
|
||||
const auth = req.header("Authorization");
|
||||
if (!installId) {
|
||||
const e = await errorEnvelope({
|
||||
error: "missing X-Recap-Install-Id header",
|
||||
statusHint: 400,
|
||||
});
|
||||
return res.status(400).json(e.body);
|
||||
}
|
||||
const invoiceId = (req.params.id || "").trim();
|
||||
if (!invoiceId) {
|
||||
const e = await errorEnvelope({
|
||||
error: "missing invoice id",
|
||||
installId,
|
||||
statusHint: 400,
|
||||
});
|
||||
return res.status(400).json(e.body);
|
||||
}
|
||||
const cfg = await getConfigSnapshot();
|
||||
const license = await resolveLicense(auth);
|
||||
try {
|
||||
const invoice = await getInvoice({
|
||||
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
|
||||
storeId: cfg.relay_btcpay_store_id,
|
||||
apiKey: cfg.relay_btcpay_api_key,
|
||||
invoiceId,
|
||||
});
|
||||
// Guard: only the install that minted this invoice can poll it.
|
||||
// BTCPay metadata may carry install_id from createInvoice; if
|
||||
// present, refuse cross-install lookups.
|
||||
const meta = invoice.metadata || {};
|
||||
if (meta.install_id && meta.install_id !== installId) {
|
||||
const e = await errorEnvelope({
|
||||
error: "invoice belongs to a different install",
|
||||
installId,
|
||||
tier: license.tier,
|
||||
statusHint: 403,
|
||||
});
|
||||
return res.status(403).json(e.body);
|
||||
}
|
||||
const row = await getOrCreateRow({ installId, license });
|
||||
const quota = await getTierQuotas();
|
||||
const balance = computeRemaining(row, quota);
|
||||
const body = await envelope({
|
||||
result: {
|
||||
invoice_id: invoice.id,
|
||||
status: normalizeStatus(invoice.status),
|
||||
status_raw: invoice.status,
|
||||
credits: Number(meta.credits) || null,
|
||||
sats: invoice.amount ? Number(invoice.amount) : null,
|
||||
checkout_url: rewriteCheckoutUrl(
|
||||
invoice.checkoutLink || null,
|
||||
cfg.relay_btcpay_public_url || cfg.relay_btcpay_base_url
|
||||
),
|
||||
balance: {
|
||||
tier_remaining: balance.remaining,
|
||||
purchased: balance.purchased,
|
||||
total: balance.total,
|
||||
},
|
||||
},
|
||||
installId,
|
||||
license,
|
||||
tier: license.tier,
|
||||
});
|
||||
res.json(body);
|
||||
} catch (err) {
|
||||
console.error(`[credits/invoice] failed: ${err?.message || err}`);
|
||||
const e = await errorEnvelope({
|
||||
error: err?.message || "btcpay_get_invoice_failed",
|
||||
installId,
|
||||
tier: license.tier,
|
||||
statusHint: 502,
|
||||
});
|
||||
return res.status(502).json(e.body);
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /relay/btcpay/webhook ───────────────────────────────────────
|
||||
// BTCPay calls this on every invoice event. We:
|
||||
// 1. Validate the HMAC-SHA256 signature against the operator-set
|
||||
// webhook secret.
|
||||
// 2. Skip anything that isn't an "InvoiceSettled" event.
|
||||
// 3. Dedupe by invoice id (BTCPay may retry on failures).
|
||||
// 4. Look up the invoice metadata, then credit the install.
|
||||
//
|
||||
// Uses express.raw() so we have the EXACT bytes BTCPay signed —
|
||||
// any whitespace canonicalization after JSON-parse would break the
|
||||
// HMAC check.
|
||||
router.post(
|
||||
"/btcpay/webhook",
|
||||
express.raw({ type: "*/*", limit: "1mb" }),
|
||||
async (req, res) => {
|
||||
const cfg = await getConfigSnapshot();
|
||||
const secret = cfg.relay_btcpay_webhook_secret;
|
||||
if (!secret) {
|
||||
console.warn("[btcpay/webhook] secret not configured — rejecting");
|
||||
return res.status(503).json({ error: "webhook_secret_not_configured" });
|
||||
}
|
||||
const sig = req.header("BTCPay-Sig") || "";
|
||||
const ok = validateWebhookSignature({
|
||||
rawBody: req.body,
|
||||
signatureHeader: sig,
|
||||
secret,
|
||||
});
|
||||
if (!ok) {
|
||||
console.warn(
|
||||
`[btcpay/webhook] signature mismatch (header=${sig.slice(0, 20)}...)`
|
||||
);
|
||||
return res.status(401).json({ error: "bad_signature" });
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(req.body.toString("utf8"));
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: "bad_json" });
|
||||
}
|
||||
|
||||
// BTCPay event types we care about. "InvoiceSettled" fires once
|
||||
// the invoice is fully paid + confirmed. We deliberately ignore
|
||||
// "InvoiceProcessing" (in-mempool, not yet confirmed) and
|
||||
// "InvoiceExpired" (timed out without payment).
|
||||
const type = payload?.type || "";
|
||||
const invoiceId = payload?.invoiceId || payload?.invoice_id || null;
|
||||
if (!invoiceId) {
|
||||
return res.status(200).json({ ok: true, ignored: "no_invoice_id" });
|
||||
}
|
||||
if (type !== "InvoiceSettled") {
|
||||
// Acknowledge to stop BTCPay from retrying, but do nothing.
|
||||
return res.status(200).json({ ok: true, ignored: `type=${type}` });
|
||||
}
|
||||
|
||||
const dedupKey = `${cfg.relay_btcpay_store_id}|${invoiceId}`;
|
||||
if (processedInvoices.has(dedupKey)) {
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "already_processed", invoiceId });
|
||||
}
|
||||
|
||||
// Pull the invoice from BTCPay to read its metadata (the webhook
|
||||
// payload doesn't carry our embedded install_id + credits
|
||||
// fields directly — they live on the invoice object).
|
||||
let invoice;
|
||||
try {
|
||||
invoice = await getInvoice({
|
||||
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
|
||||
storeId: cfg.relay_btcpay_store_id,
|
||||
apiKey: cfg.relay_btcpay_api_key,
|
||||
invoiceId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[btcpay/webhook] getInvoice failed for ${invoiceId}: ${err?.message || err}`
|
||||
);
|
||||
// Return 5xx so BTCPay retries — this is likely a transient
|
||||
// network error against BTCPay's own API.
|
||||
return res.status(502).json({ error: "btcpay_lookup_failed" });
|
||||
}
|
||||
|
||||
const meta = invoice.metadata || {};
|
||||
|
||||
// Self-serve subscription purchase (Pro/Max) — different metadata shape
|
||||
// than credit purchases. Extend the buyer's prepaid period and stop.
|
||||
if (meta.product === "recap_tier_subscription") {
|
||||
const subUserId = typeof meta.user_id === "string" ? meta.user_id : "";
|
||||
const subTier = meta.tier;
|
||||
const periodDays = Number(meta.period_days) || 30;
|
||||
if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
|
||||
processedInvoices.add(dedupKey);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "bad_tier_metadata", invoiceId });
|
||||
}
|
||||
try {
|
||||
const row = await extendUserTier({
|
||||
userId: subUserId,
|
||||
tier: subTier,
|
||||
periodDays,
|
||||
});
|
||||
processedInvoices.add(dedupKey);
|
||||
console.log(
|
||||
`[btcpay/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (invoice ${invoiceId}) → expires ${row.subscription_expires_at}`,
|
||||
);
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
tier: subTier,
|
||||
user: subUserId,
|
||||
expires_at: row.subscription_expires_at,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[btcpay/webhook] extendUserTier failed: ${err?.message || err}`,
|
||||
);
|
||||
return res.status(500).json({ error: "tier_grant_failed" });
|
||||
}
|
||||
}
|
||||
|
||||
const installId = meta.install_id;
|
||||
const credits = Number(meta.credits);
|
||||
// license_fingerprint was stashed at buy time when the buyer
|
||||
// had an active paid license. Used here to route the credit to
|
||||
// their license-keyed pool instead of an install-keyed pool.
|
||||
// Missing (older invoices, or Core buyers) → falls back to
|
||||
// install-keyed.
|
||||
const buyerFp = meta.license_fingerprint || null;
|
||||
const creditKey = buyerFp ? `lic:${buyerFp}` : null;
|
||||
if (!installId || !Number.isFinite(credits) || credits <= 0) {
|
||||
// Not ours — could be an invoice from another product on the
|
||||
// same store. Mark processed so we don't keep retrying, and
|
||||
// 200 so BTCPay stops calling.
|
||||
processedInvoices.add(dedupKey);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "no_recap_metadata" });
|
||||
}
|
||||
|
||||
try {
|
||||
const newBalance = await addPurchasedCredits({
|
||||
installId,
|
||||
creditKey,
|
||||
amount: credits,
|
||||
});
|
||||
processedInvoices.add(dedupKey);
|
||||
await recordCall({
|
||||
install_id: installId,
|
||||
license_fingerprint: buyerFp,
|
||||
tier: null,
|
||||
pipeline: "credits_purchase",
|
||||
backend: null,
|
||||
model: null,
|
||||
status: "settled",
|
||||
credit_charged: 0,
|
||||
duration_ms: 0,
|
||||
cost_usd: 0,
|
||||
purchase_credits: credits,
|
||||
purchase_sats: Number(invoice.amount) || null,
|
||||
invoice_id: invoiceId,
|
||||
purchased_balance_after: newBalance,
|
||||
});
|
||||
console.log(
|
||||
`[btcpay/webhook] credited install ${installId.slice(0, 8)}… with ${credits} credits (invoice ${invoiceId})${creditKey ? ` → pool ${creditKey}` : ""}`
|
||||
);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, credited: credits, install: installId });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[btcpay/webhook] addPurchasedCredits failed: ${err?.message || err}`
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "credit_grant_failed" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Rewrite a BTCPay checkout URL's host to the operator's browser-
|
||||
// facing URL. BTCPay builds the checkoutLink using whatever host the
|
||||
// invoice-create API call came in on — and since we hit it via
|
||||
// `http://btcpayserver.startos:23000` (the internal docker network
|
||||
// hostname), the returned URL is `btcpayserver.startos:23000/i/...`
|
||||
// which the buyer's browser can't resolve.
|
||||
//
|
||||
// Fix: parse the URL, swap origin (scheme + host + port) with the
|
||||
// operator's `relay_btcpay_base_url` (set by the one-click setup to
|
||||
// the discovered mDNS / clearnet URL). Path + query are preserved.
|
||||
// If parsing fails for any reason, return the URL unchanged — better
|
||||
// to ship a broken link than crash the buy flow.
|
||||
function rewriteCheckoutUrl(url, browserBase) {
|
||||
if (!url || !browserBase) return url;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const b = new URL(browserBase);
|
||||
u.protocol = b.protocol;
|
||||
u.hostname = b.hostname;
|
||||
// Explicitly assign port from the base — including the empty
|
||||
// string when the base has no port (HTTPS on default 443).
|
||||
// Using `u.host = b.host` looks equivalent but Node's URL keeps
|
||||
// the original port when the new host string has none, so a
|
||||
// rewrite from `btcpayserver.startos:23000` → `btcpay.keysat.xyz`
|
||||
// ended up as `btcpay.keysat.xyz:23000`. Assigning port directly
|
||||
// clears it.
|
||||
u.port = b.port || "";
|
||||
return u.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery: when the BTCPay webhook URL was broken and paid
|
||||
// invoices never got credited, this scans BTCPay's recent settled
|
||||
// invoices and grants credits for ones the relay hasn't processed.
|
||||
// Idempotent via the same processedInvoices dedup the webhook uses,
|
||||
// so re-running is safe.
|
||||
//
|
||||
// Exported so the admin route in btcpay-setup.js can call it. Not
|
||||
// exposed via /relay/* because it's operator-initiated, not buyer.
|
||||
export async function rescanSettledInvoices() {
|
||||
const cfg = await getConfigSnapshot();
|
||||
if (
|
||||
!cfg.relay_btcpay_base_url ||
|
||||
!cfg.relay_btcpay_store_id ||
|
||||
!cfg.relay_btcpay_api_key
|
||||
) {
|
||||
return { ok: false, error: "btcpay_not_configured" };
|
||||
}
|
||||
const apiBase = (
|
||||
cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url
|
||||
).replace(/\/$/, "");
|
||||
const sinceMs = Date.now() - 30 * 24 * 3600 * 1000;
|
||||
let invoices;
|
||||
try {
|
||||
const r = await fetch(
|
||||
`${apiBase}/api/v1/stores/${encodeURIComponent(cfg.relay_btcpay_store_id)}/invoices?take=1000&status=Settled&startDate=${Math.floor(sinceMs / 1000)}`,
|
||||
{ headers: { Authorization: `token ${cfg.relay_btcpay_api_key}` } }
|
||||
);
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
return {
|
||||
ok: false,
|
||||
error: "btcpay_list_invoices_failed",
|
||||
message: text.slice(0, 300),
|
||||
};
|
||||
}
|
||||
invoices = await r.json();
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "rescan_failed",
|
||||
message: (err?.message || String(err)).slice(0, 200),
|
||||
};
|
||||
}
|
||||
let credited = 0;
|
||||
let alreadyProcessed = 0;
|
||||
let skipped = 0;
|
||||
const details = [];
|
||||
for (const invoice of invoices || []) {
|
||||
const dedupKey = `${cfg.relay_btcpay_store_id}|${invoice.id}`;
|
||||
if (processedInvoices.has(dedupKey)) {
|
||||
alreadyProcessed++;
|
||||
continue;
|
||||
}
|
||||
const meta = invoice.metadata || {};
|
||||
const installId = meta.install_id;
|
||||
const credits = Number(meta.credits);
|
||||
const buyerFp = meta.license_fingerprint || null;
|
||||
const creditKey = buyerFp ? `lic:${buyerFp}` : null;
|
||||
if (
|
||||
!installId ||
|
||||
!Number.isFinite(credits) ||
|
||||
credits <= 0 ||
|
||||
meta.product !== "recap_credits"
|
||||
) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const newBalance = await addPurchasedCredits({
|
||||
installId,
|
||||
creditKey,
|
||||
amount: credits,
|
||||
});
|
||||
processedInvoices.add(dedupKey);
|
||||
await recordCall({
|
||||
install_id: installId,
|
||||
license_fingerprint: buyerFp,
|
||||
tier: null,
|
||||
pipeline: "credits_purchase",
|
||||
backend: null,
|
||||
model: null,
|
||||
status: "settled",
|
||||
credit_charged: 0,
|
||||
duration_ms: 0,
|
||||
cost_usd: 0,
|
||||
purchase_credits: credits,
|
||||
purchase_sats: Number(invoice.amount) || null,
|
||||
invoice_id: invoice.id,
|
||||
purchased_balance_after: newBalance,
|
||||
recovery_rescan: true,
|
||||
});
|
||||
credited++;
|
||||
details.push({
|
||||
invoice_id: invoice.id,
|
||||
install: installId.slice(0, 8),
|
||||
credits,
|
||||
new_balance: newBalance,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[credits/rescan] addPurchasedCredits failed for ${invoice.id}: ${err?.message || err}`
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[credits/rescan] credited=${credited} already=${alreadyProcessed} skipped=${skipped}`
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
credited,
|
||||
already_processed: alreadyProcessed,
|
||||
skipped,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
// BTCPay's status enum → a coarser one Recap can render off of.
|
||||
function normalizeStatus(s) {
|
||||
switch ((s || "").toLowerCase()) {
|
||||
case "new":
|
||||
return "new";
|
||||
case "processing":
|
||||
return "processing";
|
||||
case "settled":
|
||||
case "complete":
|
||||
return "settled";
|
||||
case "expired":
|
||||
return "expired";
|
||||
case "invalid":
|
||||
return "invalid";
|
||||
default:
|
||||
return (s || "unknown").toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// POST /relay/user-tier + GET /relay/user-tier/:userId (core-decoupling)
|
||||
//
|
||||
// Operator-only. The cloud Recaps server (recaps.cc) calls these to SET and
|
||||
// READ a cloud user's Pro/Max tier — the relay is the source of truth for
|
||||
// cloud tiers, so granting Pro/Max means writing the user's credit row
|
||||
// here. Authenticated by the shared operator key (X-Recap-Operator-Key);
|
||||
// no per-user Keysat license is involved.
|
||||
//
|
||||
// POST body: { user_id, tier: "core"|"pro"|"max", expires_at?: ISO }
|
||||
// (self-serve subscription purchase is a later slice; for now tiers are
|
||||
// operator-set, and "revoke" = set tier back to "core".)
|
||||
|
||||
import express from "express";
|
||||
import { verifyOperatorKey, isSubscriptionExpired } from "../identity.js";
|
||||
import {
|
||||
setUserTier,
|
||||
getUserCreditRow,
|
||||
computeRemaining,
|
||||
snapshotAll,
|
||||
} from "../credits.js";
|
||||
import {
|
||||
getTierQuotas,
|
||||
getTierPricesSats,
|
||||
getTierPricesFiatCents,
|
||||
getSubscriptionPeriodDays,
|
||||
getZapriteConfig,
|
||||
getConfigSnapshot,
|
||||
} from "../config.js";
|
||||
import {
|
||||
createTierInvoice,
|
||||
getInvoicePaymentMethods,
|
||||
pickLightningFromPaymentMethods,
|
||||
} from "../btcpay-client.js";
|
||||
import { createOrder as createZapriteOrder } from "../zaprite-client.js";
|
||||
|
||||
const TIERS = new Set(["core", "pro", "max"]);
|
||||
const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
|
||||
async function reportRow(userId, row) {
|
||||
const quota = await getTierQuotas();
|
||||
const balance = computeRemaining(row, quota);
|
||||
// `tier` is the EFFECTIVE tier (expiry-enforced) — what callers gate on
|
||||
// and what Recaps caches. `tier_snapshot` is the raw stored value, so the
|
||||
// operator can still see "paid but lapsed".
|
||||
const expired = isSubscriptionExpired(row);
|
||||
const effectiveTier = expired ? "core" : row.tier_snapshot || "core";
|
||||
return {
|
||||
ok: true,
|
||||
user_id: userId,
|
||||
tier: effectiveTier,
|
||||
tier_snapshot: row.tier_snapshot || "core",
|
||||
subscription_expired: expired,
|
||||
subscription_expires_at: row.subscription_expires_at || null,
|
||||
credits_remaining: balance.total, // null = unlimited (Max)
|
||||
tier_remaining: balance.remaining,
|
||||
purchased_balance: balance.purchased,
|
||||
};
|
||||
}
|
||||
|
||||
export function userTierRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/user-tier", express.json({ limit: "16kb" }), async (req, res) => {
|
||||
if (!(await verifyOperatorKey(req))) {
|
||||
return res.status(401).json({ error: "invalid_operator_key" });
|
||||
}
|
||||
const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : "";
|
||||
const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : "";
|
||||
const expiresAt =
|
||||
typeof req.body?.expires_at === "string" && req.body.expires_at.trim()
|
||||
? req.body.expires_at.trim()
|
||||
: null;
|
||||
if (!USER_ID_RE.test(userId)) {
|
||||
return res.status(400).json({ error: "invalid_user_id" });
|
||||
}
|
||||
if (!TIERS.has(tier)) {
|
||||
return res.status(400).json({ error: "tier_must_be_core_pro_or_max" });
|
||||
}
|
||||
const row = await setUserTier({ userId, tier, expiresAt });
|
||||
console.log(`[user-tier] set ${userId} → ${tier}${expiresAt ? ` (expires ${expiresAt})` : ""}`);
|
||||
res.json(await reportRow(userId, row));
|
||||
});
|
||||
|
||||
// Create a BTCPay invoice to buy a prepaid period of pro/max for a user.
|
||||
// The Recaps server calls this (operator-key authed) when a signed-in user
|
||||
// hits "Pay with Bitcoin"; it returns the checkout URL + invoice id. On
|
||||
// settlement the BTCPay webhook (routes/credits.js) calls extendUserTier.
|
||||
router.post("/tier-invoice", express.json({ limit: "16kb" }), async (req, res) => {
|
||||
if (!(await verifyOperatorKey(req))) {
|
||||
return res.status(401).json({ error: "invalid_operator_key" });
|
||||
}
|
||||
const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : "";
|
||||
const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : "";
|
||||
const returnUrl =
|
||||
typeof req.body?.return_url === "string" ? req.body.return_url.trim() : "";
|
||||
if (!USER_ID_RE.test(userId)) {
|
||||
return res.status(400).json({ error: "invalid_user_id" });
|
||||
}
|
||||
if (tier !== "pro" && tier !== "max") {
|
||||
return res.status(400).json({ error: "tier_must_be_pro_or_max" });
|
||||
}
|
||||
const prices = await getTierPricesSats();
|
||||
const sats = prices[tier];
|
||||
if (!Number.isFinite(sats) || sats <= 0) {
|
||||
return res.status(400).json({ error: "tier_not_priced" });
|
||||
}
|
||||
const periodDays = await getSubscriptionPeriodDays();
|
||||
|
||||
const cfg = await getConfigSnapshot();
|
||||
if (
|
||||
!cfg.relay_btcpay_base_url ||
|
||||
!cfg.relay_btcpay_store_id ||
|
||||
!cfg.relay_btcpay_api_key
|
||||
) {
|
||||
return res.status(503).json({ error: "btcpay_not_configured" });
|
||||
}
|
||||
try {
|
||||
const invoice = await createTierInvoice({
|
||||
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
|
||||
storeId: cfg.relay_btcpay_store_id,
|
||||
apiKey: cfg.relay_btcpay_api_key,
|
||||
sats,
|
||||
userId,
|
||||
tier,
|
||||
periodDays,
|
||||
redirectURL: returnUrl || undefined,
|
||||
redirectAutomatically: !!returnUrl,
|
||||
});
|
||||
// Prefer the operator's public BTCPay host for the buyer-facing link
|
||||
// (the invoice may have been created against an internal URL).
|
||||
let checkoutUrl = invoice.checkoutLink || null;
|
||||
if (checkoutUrl && cfg.relay_btcpay_public_url) {
|
||||
try {
|
||||
const u = new URL(checkoutUrl);
|
||||
const pub = new URL(cfg.relay_btcpay_public_url);
|
||||
u.protocol = pub.protocol;
|
||||
u.host = pub.host;
|
||||
checkoutUrl = u.toString();
|
||||
} catch {}
|
||||
}
|
||||
// Fetch the Lightning BOLT11 so the Recaps app can render an INLINE
|
||||
// QR/invoice (no redirect to the hosted BTCPay page) — same as the
|
||||
// credit-pack flow. BTCPay generates the LN invoice asynchronously on
|
||||
// some configs, so retry once after a short backoff. On total failure
|
||||
// bolt11 stays null and the app falls back to the hosted checkout_url.
|
||||
let bolt11 = null;
|
||||
let lightningPaymentLink = null;
|
||||
let lnDebug = null;
|
||||
try {
|
||||
const pmArgs = {
|
||||
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
|
||||
storeId: cfg.relay_btcpay_store_id,
|
||||
apiKey: cfg.relay_btcpay_api_key,
|
||||
invoiceId: invoice.id,
|
||||
};
|
||||
let methods = await getInvoicePaymentMethods(pmArgs);
|
||||
let ln = pickLightningFromPaymentMethods(methods);
|
||||
if (!ln) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
methods = await getInvoicePaymentMethods(pmArgs);
|
||||
ln = pickLightningFromPaymentMethods(methods);
|
||||
}
|
||||
if (ln) {
|
||||
bolt11 = ln.bolt11;
|
||||
lightningPaymentLink = ln.paymentLink;
|
||||
} else {
|
||||
// Capture a sanitized sample of the payment-methods response so
|
||||
// operators can diagnose why no BOLT11 came back (BTCPay version
|
||||
// differences, LN not configured, etc.) — same diagnostic the
|
||||
// credit-pack flow records.
|
||||
const sample = (Array.isArray(methods) ? methods : [])
|
||||
.slice(0, 3)
|
||||
.map((m) => {
|
||||
const out = {};
|
||||
for (const k of Object.keys(m || {})) {
|
||||
if (k === "payments") continue;
|
||||
const v = m[k];
|
||||
out[k] =
|
||||
v === null || ["string", "number", "boolean"].includes(typeof v)
|
||||
? v
|
||||
: `<${typeof v}>`;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
lnDebug = {
|
||||
reason: "no_lightning_method",
|
||||
methods_count: Array.isArray(methods) ? methods.length : 0,
|
||||
sample,
|
||||
};
|
||||
console.warn(
|
||||
`[tier-invoice] invoice ${invoice.id}: no Lightning method on payment-methods — falling back to hosted checkout. sample=${JSON.stringify(sample)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
lnDebug = {
|
||||
reason: "fetch_failed",
|
||||
message: (err?.message || String(err)).slice(0, 300),
|
||||
};
|
||||
console.warn(
|
||||
`[tier-invoice] invoice ${invoice.id}: payment-methods fetch failed (${err?.message || err}) — falling back to hosted checkout`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[tier-invoice] ${tier} ${sats} sats / ${periodDays}d for ${userId.slice(0, 8)}… (invoice ${invoice.id}, ln=${bolt11 ? "yes" : "no"})`,
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
invoice_id: invoice.id,
|
||||
checkout_url: checkoutUrl,
|
||||
sats,
|
||||
tier,
|
||||
period_days: periodDays,
|
||||
bolt11,
|
||||
lightning_payment_link: lightningPaymentLink,
|
||||
lightning_expires_at: invoice.expirationTime || null,
|
||||
expires_at: invoice.expirationTime || null,
|
||||
_ln_debug: lnDebug,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[tier-invoice] createTierInvoice failed: ${err?.message || err}`);
|
||||
res
|
||||
.status(502)
|
||||
.json({ error: "invoice_create_failed", message: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a Zaprite hosted-checkout order to buy a prepaid Pro/Max period
|
||||
// for a user with a CARD. Operator-key authed (the Recaps server proxies
|
||||
// this when a signed-in user clicks "Pay by card"). Returns the checkout
|
||||
// URL + order id. On settlement the Zaprite webhook (below) re-fetches
|
||||
// the order and calls extendUserTier — the same landing point as the
|
||||
// BTCPay rail.
|
||||
router.post("/tier-zaprite-order", express.json({ limit: "16kb" }), async (req, res) => {
|
||||
if (!(await verifyOperatorKey(req))) {
|
||||
return res.status(401).json({ error: "invalid_operator_key" });
|
||||
}
|
||||
const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : "";
|
||||
const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : "";
|
||||
const returnUrl =
|
||||
typeof req.body?.return_url === "string" ? req.body.return_url.trim() : "";
|
||||
if (!USER_ID_RE.test(userId)) {
|
||||
return res.status(400).json({ error: "invalid_user_id" });
|
||||
}
|
||||
if (tier !== "pro" && tier !== "max") {
|
||||
return res.status(400).json({ error: "tier_must_be_pro_or_max" });
|
||||
}
|
||||
const prices = await getTierPricesFiatCents();
|
||||
const amount = prices[tier];
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return res.status(400).json({ error: "tier_not_priced" });
|
||||
}
|
||||
const periodDays = await getSubscriptionPeriodDays();
|
||||
const zaprite = await getZapriteConfig();
|
||||
if (!zaprite.apiKey) {
|
||||
return res.status(503).json({ error: "zaprite_not_configured" });
|
||||
}
|
||||
try {
|
||||
const order = await createZapriteOrder({
|
||||
baseURL: zaprite.baseUrl,
|
||||
apiKey: zaprite.apiKey,
|
||||
amount,
|
||||
currency: zaprite.currency,
|
||||
label: `Recaps ${tier.toUpperCase()} — ${periodDays} days`,
|
||||
metadata: {
|
||||
product: "recap_tier_subscription",
|
||||
user_id: userId,
|
||||
tier,
|
||||
period_days: periodDays,
|
||||
},
|
||||
redirectUrl: returnUrl || undefined,
|
||||
});
|
||||
const checkoutUrl = order?.checkoutUrl || null;
|
||||
if (!order?.id || !checkoutUrl) {
|
||||
throw new Error("Zaprite order missing id/checkoutUrl");
|
||||
}
|
||||
console.log(
|
||||
`[tier-zaprite] ${tier} ${amount} ${zaprite.currency} / ${periodDays}d for ${userId.slice(0, 8)}… (order ${order.id})`,
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
order_id: order.id,
|
||||
checkout_url: checkoutUrl,
|
||||
amount,
|
||||
currency: zaprite.currency,
|
||||
tier,
|
||||
period_days: periodDays,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[tier-zaprite] createOrder failed: ${err?.message || err}`);
|
||||
res
|
||||
.status(502)
|
||||
.json({ error: "zaprite_order_failed", message: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// List the buyable subscription plans + their sats prices. Operator-key
|
||||
// authed (the Recaps server proxies this to its purchase UI so the tier
|
||||
// prices stay sourced from the relay's config, never hardcoded in the
|
||||
// app). Returns { ok, period_days, plans: [{tier, sats}] }.
|
||||
router.get("/tier-plans", async (req, res) => {
|
||||
if (!(await verifyOperatorKey(req))) {
|
||||
return res.status(401).json({ error: "invalid_operator_key" });
|
||||
}
|
||||
const prices = await getTierPricesSats();
|
||||
const fiat = await getTierPricesFiatCents();
|
||||
const periodDays = await getSubscriptionPeriodDays();
|
||||
const zaprite = await getZapriteConfig();
|
||||
const quotas = await getTierQuotas();
|
||||
const cardAvailable = !!zaprite.apiKey;
|
||||
const plans = ["pro", "max"]
|
||||
.map((tier) => ({
|
||||
tier,
|
||||
sats: prices[tier],
|
||||
// Card-rail price in the currency's smallest unit (cents for USD).
|
||||
fiat_amount: fiat[tier],
|
||||
fiat_currency: zaprite.currency,
|
||||
// Monthly relay-credit allotment for this tier, sourced from the
|
||||
// operator's Adjust-Tier-Quotas config. A number is the real
|
||||
// per-period credit count (e.g. Pro 50); null means unlimited.
|
||||
// The card shows whichever — so it always reflects the live config.
|
||||
credits_per_period:
|
||||
typeof quotas?.[tier]?.monthly === "number"
|
||||
? quotas[tier].monthly
|
||||
: null,
|
||||
}))
|
||||
.filter((p) => Number.isFinite(p.sats) && p.sats > 0);
|
||||
res.json({
|
||||
ok: true,
|
||||
period_days: periodDays,
|
||||
plans,
|
||||
// The UI hides the "Pay by card" link when the operator hasn't
|
||||
// configured Zaprite (so it never offers a rail that 503s).
|
||||
card_available: cardAvailable,
|
||||
});
|
||||
});
|
||||
|
||||
// List cloud users whose prepaid period expires within the next
|
||||
// `within_days` OR lapsed within the last `lapsed_days`. Operator-key
|
||||
// authed. The relay owns the expiry (it's the subscription source of
|
||||
// truth), but not the email — so the Recaps server calls this to decide
|
||||
// who to send expiry-reminder emails to, then maps user_id → email on
|
||||
// its side. Returns { ok, now, subscriptions: [{user_id, tier,
|
||||
// expires_at, expired, days_left}] }, paid tiers only.
|
||||
router.get("/expiring-subscriptions", async (req, res) => {
|
||||
if (!(await verifyOperatorKey(req))) {
|
||||
return res.status(401).json({ error: "invalid_operator_key" });
|
||||
}
|
||||
const clampInt = (v, def, lo, hi) => {
|
||||
const n = parseInt(v, 10);
|
||||
if (!Number.isFinite(n)) return def;
|
||||
return Math.max(lo, Math.min(hi, n));
|
||||
};
|
||||
const withinDays = clampInt(req.query.within_days, 7, 0, 120);
|
||||
const lapsedDays = clampInt(req.query.lapsed_days, 3, 0, 120);
|
||||
const now = Date.now();
|
||||
const DAY = 86_400_000;
|
||||
const upperMs = now + withinDays * DAY;
|
||||
const lowerMs = now - lapsedDays * DAY;
|
||||
const out = [];
|
||||
for (const row of snapshotAll()) {
|
||||
const key = row.credit_key || "";
|
||||
if (!key.startsWith("user:")) continue;
|
||||
const tier = row.tier_snapshot || "core";
|
||||
if (tier !== "pro" && tier !== "max") continue;
|
||||
const exp = row.subscription_expires_at;
|
||||
if (!exp) continue; // open-ended grant (operator comp) — never expires
|
||||
const t = new Date(exp).getTime();
|
||||
if (!Number.isFinite(t)) continue;
|
||||
if (t > upperMs || t < lowerMs) continue; // outside the reminder window
|
||||
out.push({
|
||||
user_id: key.slice("user:".length),
|
||||
tier,
|
||||
expires_at: exp,
|
||||
expired: t < now,
|
||||
days_left: Math.ceil((t - now) / DAY),
|
||||
});
|
||||
}
|
||||
res.json({ ok: true, now: new Date(now).toISOString(), subscriptions: out });
|
||||
});
|
||||
|
||||
router.get("/user-tier/:userId", async (req, res) => {
|
||||
if (!(await verifyOperatorKey(req))) {
|
||||
return res.status(401).json({ error: "invalid_operator_key" });
|
||||
}
|
||||
const userId = (req.params.userId || "").trim();
|
||||
if (!USER_ID_RE.test(userId)) {
|
||||
return res.status(400).json({ error: "invalid_user_id" });
|
||||
}
|
||||
const row = await getUserCreditRow(userId);
|
||||
res.json(await reportRow(userId, row));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// POST /relay/zaprite/webhook — card-rail settlement handler.
|
||||
//
|
||||
// Zaprite calls this when an order's activity changes. We do NOT trust the
|
||||
// webhook body to decide whether money landed (Zaprite's webhook-signing
|
||||
// mechanism isn't publicly documented). Instead we VERIFY by re-fetching
|
||||
// the order from Zaprite's authenticated API and checking its status —
|
||||
// the same re-fetch-to-verify pattern the BTCPay handler uses. The body is
|
||||
// only a nudge that carries the order id.
|
||||
//
|
||||
// On a paid order tagged product:"recap_tier_subscription", we extend the
|
||||
// buyer's prepaid period via extendUserTier — the SAME landing point as
|
||||
// the BTCPay (Bitcoin) rail, so both rails converge on one tier-grant path.
|
||||
|
||||
import express from "express";
|
||||
import { extendUserTier } from "../credits.js";
|
||||
import { getZapriteConfig } from "../config.js";
|
||||
import { getOrder, isOrderPaid, orderIdFromWebhook } from "../zaprite-client.js";
|
||||
|
||||
// In-memory dedup of fully-processed orders (mirrors the BTCPay handler's
|
||||
// processedInvoices). Zaprite retries on non-200, and may fire multiple
|
||||
// events per order, so we guard the grant. Cleared on restart — a
|
||||
// re-delivered webhook after restart re-fetches + re-grants, but
|
||||
// extendUserTier is keyed per order via this set within a process; a
|
||||
// duplicate grant across a restart is the same harmless "extend by one
|
||||
// period" the operator-comp path already tolerates. (Acceptable: card
|
||||
// double-fires across a restart are vanishingly rare.)
|
||||
const processedZaprite = new Set();
|
||||
|
||||
export function zapriteWebhookRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// express.raw so a malformed/empty body can't 400 the webhook into an
|
||||
// infinite Zaprite retry loop — we parse defensively and always 200
|
||||
// unless we genuinely want a retry (transient lookup failure → 5xx).
|
||||
router.post(
|
||||
"/zaprite/webhook",
|
||||
express.raw({ type: "*/*", limit: "1mb" }),
|
||||
async (req, res) => {
|
||||
let payload = {};
|
||||
try {
|
||||
const txt = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
|
||||
payload = txt ? JSON.parse(txt) : {};
|
||||
} catch {
|
||||
return res.status(200).json({ ok: true, ignored: "bad_json" });
|
||||
}
|
||||
|
||||
const orderId = orderIdFromWebhook(payload);
|
||||
if (!orderId) {
|
||||
return res.status(200).json({ ok: true, ignored: "no_order_id" });
|
||||
}
|
||||
const dedupKey = `zaprite:${orderId}`;
|
||||
if (processedZaprite.has(dedupKey)) {
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "already_processed", orderId });
|
||||
}
|
||||
|
||||
const zaprite = await getZapriteConfig();
|
||||
if (!zaprite.apiKey) {
|
||||
// Can't verify without the API key. 200 so Zaprite stops retrying.
|
||||
console.warn("[zaprite/webhook] received but Zaprite not configured");
|
||||
return res.status(200).json({ ok: true, ignored: "not_configured" });
|
||||
}
|
||||
|
||||
// Re-fetch the order — this is the authoritative status + metadata.
|
||||
let order;
|
||||
try {
|
||||
order = await getOrder({
|
||||
baseURL: zaprite.baseUrl,
|
||||
apiKey: zaprite.apiKey,
|
||||
orderId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[zaprite/webhook] getOrder failed for ${orderId}: ${err?.message || err}`,
|
||||
);
|
||||
// 5xx → Zaprite retries; likely a transient network/API blip.
|
||||
return res.status(502).json({ error: "order_lookup_failed" });
|
||||
}
|
||||
|
||||
if (!isOrderPaid(order)) {
|
||||
// PENDING / PROCESSING / UNDERPAID — ack but don't grant, and DON'T
|
||||
// mark processed: a later settle webhook for this order should be
|
||||
// allowed to grant once it's actually paid.
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
ignored: `status=${order?.status || "unknown"}`,
|
||||
orderId,
|
||||
});
|
||||
}
|
||||
|
||||
const meta = order.metadata || {};
|
||||
if (meta.product !== "recap_tier_subscription") {
|
||||
processedZaprite.add(dedupKey);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "not_a_tier_order", orderId });
|
||||
}
|
||||
const subUserId = typeof meta.user_id === "string" ? meta.user_id : "";
|
||||
const subTier = meta.tier;
|
||||
const periodDays = Number(meta.period_days) || 30;
|
||||
if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
|
||||
processedZaprite.add(dedupKey);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "bad_tier_metadata", orderId });
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await extendUserTier({
|
||||
userId: subUserId,
|
||||
tier: subTier,
|
||||
periodDays,
|
||||
});
|
||||
processedZaprite.add(dedupKey);
|
||||
console.log(
|
||||
`[zaprite/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (order ${orderId}) → expires ${row.subscription_expires_at}`,
|
||||
);
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
tier: subTier,
|
||||
user: subUserId,
|
||||
expires_at: row.subscription_expires_at,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[zaprite/webhook] extendUserTier failed: ${err?.message || err}`,
|
||||
);
|
||||
return res.status(500).json({ error: "tier_grant_failed" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in New Issue
Block a user