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

This commit is contained in:
Keysat
2026-06-13 13:36:05 -05:00
parent 84d56c94c9
commit 0aa648706e
17 changed files with 3781 additions and 116 deletions
+43 -26
View File
@@ -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,
});
+626
View File
@@ -0,0 +1,626 @@
// One-click BTCPay setup. Three admin endpoints + a tiny standalone
// HTML page that drives the flow:
//
// POST /admin/btcpay/start — generates an authorize URL
// the operator opens in a
// browser, with all the
// right scopes pre-checked.
// Body: { btcpay_url }
// Returns: { authorize_url }
//
// POST /admin/btcpay/callback — receives the API key from
// BTCPay after the operator
// approves. Body comes from
// BTCPay's POST redirect.
// Stores key in config, returns
// { stores: [...] } so the
// operator can pick which
// store to use.
//
// POST /admin/btcpay/finalize — sets the chosen store, then
// creates the webhook
// automatically. Body:
// { store_id, public_relay_url }
// Stores webhook secret in
// config too. Returns
// { ok: true, webhook_url }.
//
// This replaces the 4-step manual setup (generate API key in BTCPay,
// generate webhook + copy secret, copy store ID, paste all four into
// Set BTCPay Connection action) with: paste BTCPay URL → click
// approve → pick store → done.
import express from "express";
import crypto from "crypto";
import fs from "fs/promises";
import path from "path";
import { getConfigSnapshot } from "../config.js";
import { rescanSettledInvoices } from "./credits.js";
export function btcpaySetupRouter({ dataDir }) {
const router = express.Router();
// Step 1: build the authorize URL. The operator hits this from the
// setup page (or by calling it directly via curl) with their
// BTCPay base URL, and we return the URL they should open in a
// browser to grant Recap Relay the permissions it needs.
//
// BTCPay's API Key Authorization Flow:
// GET /api-keys/authorize
// ?permissions=btcpay.store.cancreateinvoice
// &permissions=btcpay.store.canviewinvoices
// &permissions=btcpay.store.webhooks.canmodifywebhooks
// &applicationName=Recap+Relay
// &applicationIdentifier=recap-relay
// &strict=true — buyer can't tweak the scopes we asked
// &selectiveStores=true — let buyer pick the target store
// &redirect=<our callback> (POST redirect, not GET)
router.post("/start", express.json(), async (req, res) => {
const btcpayUrl = (req.body?.btcpay_url || "").trim();
if (!btcpayUrl || !/^https?:\/\//i.test(btcpayUrl)) {
return res.status(400).json({
error: "btcpay_url_required",
message: "Provide btcpay_url (e.g. https://btcpay.keysat.xyz).",
});
}
// If BTCPay is co-installed on the same Start9 box, the setup
// discovery file gives us the internal hostname (resolvable from
// inside this container) — needed because the browser-facing
// URL (mDNS .local or clearnet) often isn't resolvable from
// inside the docker network. We persist BOTH so the callback +
// finalize phases know which URL to use for server-to-server
// BTCPay API calls.
const internalUrl = await readDiscoveredInternalUrl(dataDir);
const publicUrl = await readDiscoveredPublicUrl(dataDir);
const state = crypto.randomBytes(24).toString("hex");
const baseRelayUrl = absoluteRelayUrl(req);
const callbackUrl = `${baseRelayUrl}/admin/btcpay/callback?state=${state}`;
// Permissions mirror Keysat's working set. Specifically:
// - canviewstoresettings: required to list stores (we call
// /api/v1/stores in the picker page)
// - canmodifystoresettings: required to register the webhook
// (BTCPay treats webhook management as a store setting, not
// a separate scope)
// - canviewinvoices / cancreateinvoice / canmodifyinvoices:
// the actual credit-purchase flow
const params = new URLSearchParams();
params.append("permissions", "btcpay.store.canviewstoresettings");
params.append("permissions", "btcpay.store.canmodifystoresettings");
params.append("permissions", "btcpay.store.canviewinvoices");
params.append("permissions", "btcpay.store.cancreateinvoice");
params.append("permissions", "btcpay.store.canmodifyinvoices");
params.append("applicationName", "Recap Relay");
params.append("applicationIdentifier", "recap-relay");
params.append("strict", "true");
params.append("selectiveStores", "true");
params.append("redirect", callbackUrl);
const authorizeUrl =
btcpayUrl.replace(/\/$/, "") + "/api-keys/authorize?" + params.toString();
await stashSetupContext(dataDir, {
btcpay_url: btcpayUrl,
btcpay_internal_url: internalUrl,
btcpay_public_url: publicUrl,
state,
});
res.json({ authorize_url: authorizeUrl, callback_url: callbackUrl });
});
// Step 2: BTCPay POST-redirects the operator's browser back here
// with the API key + permissions in the body. Body shape:
// { apiKey: "...", permissions: ["btcpay.store.X:STOREID", ...] }
//
// We persist the key into config, look up the store list, and
// return it so the operator can pick which store to use.
router.post(
"/callback",
express.urlencoded({ extended: true }),
express.json(),
async (req, res) => {
const body = req.body || {};
const apiKey =
body.apiKey ||
body.api_key ||
(typeof body === "string" ? body : null);
const permissions = Array.isArray(body.permissions)
? body.permissions
: [];
if (!apiKey || typeof apiKey !== "string") {
// Show a plain HTML response since this is a browser redirect.
return res.status(400).send(html("Authorization failed", `
<p>BTCPay didn't return an API key — likely you clicked Deny,
or your BTCPay version doesn't support the authorization flow.
You can still set up manually via the StartOS action.</p>
<p><a href="/dashboard.html">Back to dashboard</a></p>
`));
}
const ctx = await readSetupContext(dataDir);
const btcpayUrl = ctx?.btcpay_url;
const expectedState = ctx?.state;
const providedState = (req.query?.state || "").toString();
if (!btcpayUrl || !expectedState) {
return res.status(400).send(html("Setup context lost", `
<p>No BTCPay URL is on file — re-start the setup from the
dashboard.</p>
<p><a href="/dashboard.html">Back to dashboard</a></p>
`));
}
if (
!providedState ||
providedState.length !== expectedState.length ||
!crypto.timingSafeEqual(
Buffer.from(providedState),
Buffer.from(expectedState)
)
) {
return res.status(403).send(html("Bad state token", `
<p>The authorization callback didn't carry a matching state
token. This usually means someone else's setup is in flight,
or the link was tampered with. Re-start setup from the
dashboard.</p>
<p><a href="/dashboard.html">Back to dashboard</a></p>
`));
}
// Preserve the fields /start stashed (especially
// btcpay_internal_url), don't overwrite the whole object.
// Without the merge, the picker page's /stores call falls back
// to btcpay_url (mDNS) which can't resolve inside the docker
// container → "getaddrinfo ENOTFOUND".
const mergedCtx = {
...(ctx || {}),
btcpay_url: btcpayUrl,
api_key: apiKey,
permissions,
};
await stashSetupContext(dataDir, mergedCtx);
// ── Auto-finalize when the operator authorized for exactly
// one store ──
// BTCPay's authorize page already has a built-in store picker
// when we pass selectiveStores=true. The granted permissions
// encode the chosen store ID(s) in `btcpay.store.X:STOREID`
// suffixes. When only one store was selected, our second
// picker is redundant — skip straight to finalize.
const storeIds = extractStoreIdsFromPermissions(permissions);
if (storeIds.length === 1) {
const baseRelayUrl = absoluteRelayUrl(req);
const result = await finalizeBtcpay({
ctx: mergedCtx,
storeId: storeIds[0],
dataDir,
baseRelayUrl,
});
if (result.ok) {
return res.send(html("Connected", `
<h1 style="color:#86efac;">✓ BTCPay connected successfully.</h1>
<p>You can close this tab and return to the Recap Relay
dashboard. The webhook is wired up automatically and
users can now top up their credit balance via Lightning.</p>
`));
}
// Auto-finalize failed (e.g. webhook creation hiccup). Fall
// through to the manual picker so the operator can retry.
console.warn(
`[btcpay/callback] auto-finalize failed for sole store ${storeIds[0]}: ${result.error} ${result.message?.slice(0, 200)}`
);
}
// Hand the operator a tiny picker page that calls back into
// /admin/btcpay/finalize once they pick.
return res.send(html("Pick your store", `
<h1>Pick your Recap store</h1>
<p>Authorization successful. One last step — pick which BTCPay
store invoices should be created against, and the relay will
finish wiring everything up (including the webhook) for you.</p>
<div id="status">Loading stores…</div>
<div id="stores"></div>
<script>
(async () => {
const r = await fetch("/admin/btcpay/stores");
const data = await r.json().catch(() => ({}));
if (!r.ok || !Array.isArray(data.stores)) {
document.getElementById("status").innerText =
"Couldn't list stores: " + (data.message || data.error || r.status);
return;
}
const list = document.getElementById("stores");
data.stores.forEach((s) => {
const btn = document.createElement("button");
btn.textContent = s.name + " (" + s.id.slice(0, 8) + "…)";
btn.style.cssText =
"display:block;margin:8px 0;padding:10px 14px;background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:8px;cursor:pointer;font-size:13px;width:100%;text-align:left;";
btn.onclick = async () => {
btn.disabled = true;
btn.textContent = "Setting up webhook…";
const r2 = await fetch("/admin/btcpay/finalize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ store_id: s.id }),
});
const data2 = await r2.json().catch(() => ({}));
if (!r2.ok) {
document.getElementById("status").innerHTML =
'<span style="color:#fca5a5;">Setup failed: ' +
(data2.message || data2.error || r2.status) + '</span>';
btn.disabled = false;
btn.textContent = s.name + " (" + s.id.slice(0, 8) + "…)";
return;
}
document.getElementById("status").innerHTML =
'<span style="color:#86efac;">✓ Connected to ' +
s.name + ' and webhook auto-created. You can close this tab.</span>';
document.getElementById("stores").style.display = "none";
};
list.appendChild(btn);
});
document.getElementById("status").innerText = "Pick a store:";
})();
</script>
`));
}
);
// Lists stores reachable with the API key from setup context. The
// store-picker page above calls this.
router.get("/stores", async (_req, res) => {
const ctx = await readSetupContext(dataDir);
if (!ctx?.btcpay_url || !ctx?.api_key) {
console.warn("[btcpay/stores] setup_context_missing — operator may need to restart connect flow");
return res
.status(400)
.json({ error: "setup_context_missing" });
}
// Server-to-server: prefer internal hostname when discovered
// (mDNS .local doesn't resolve inside docker; clearnet works but
// takes the long way round).
const apiBase = (ctx.btcpay_internal_url || ctx.btcpay_url).replace(/\/$/, "");
const url = `${apiBase}/api/v1/stores`;
console.log(`[btcpay/stores] GET ${url}`);
try {
const r = await fetch(url, {
headers: { Authorization: `token ${ctx.api_key}` },
});
console.log(`[btcpay/stores] BTCPay responded ${r.status}`);
if (!r.ok) {
const text = await r.text();
console.warn(`[btcpay/stores] non-ok body: ${text.slice(0, 200)}`);
return res.status(502).json({
error: "btcpay_stores_failed",
message: text.slice(0, 200),
});
}
const stores = await r.json();
console.log(`[btcpay/stores] returned ${Array.isArray(stores) ? stores.length : "?"} stores`);
res.json({ stores });
} catch (err) {
const msg = err?.message || String(err);
const cause = err?.cause?.message || err?.cause?.code || err?.cause;
console.error(
`[btcpay/stores] fetch threw: ${msg}${cause ? ` (cause: ${cause})` : ""} url=${url}`
);
res.status(502).json({
error: "btcpay_stores_failed",
message: msg.slice(0, 200) + (cause ? ` (cause: ${String(cause).slice(0, 120)})` : ""),
});
}
});
// Step 3: finalize. Pin the chosen store, create a webhook
// pointing back at /relay/btcpay/webhook, and write all four
// BTCPay fields to the live config.
router.post("/finalize", express.json(), async (req, res) => {
const storeId = (req.body?.store_id || "").trim();
if (!storeId) {
return res.status(400).json({ error: "store_id_required" });
}
const ctx = await readSetupContext(dataDir);
if (!ctx?.btcpay_url || !ctx?.api_key) {
return res.status(400).json({ error: "setup_context_missing" });
}
const baseRelayUrl = absoluteRelayUrl(req);
const result = await finalizeBtcpay({ ctx, storeId, dataDir, baseRelayUrl });
if (!result.ok) {
return res.status(result.status).json({
error: result.error,
message: result.message,
});
}
res.json({ ok: true, webhook_url: result.webhookUrl });
});
// Auto-discovery: does the StartOS init phase know about a local
// BTCPay install? Reads the file written by startos/init/setup.ts
// via sdk.serviceInterface.getAll. When present, the dashboard
// skips the URL-prompt step and goes straight to /start with the
// discovered URL.
router.get("/discover", async (_req, res) => {
const discoveryPath = path.join(dataDir, "discovered-services.json");
try {
const raw = await fs.readFile(discoveryPath, "utf8");
const parsed = JSON.parse(raw);
if (parsed?.btcpay?.browser_url) {
return res.json({
found: true,
browser_url: parsed.btcpay.browser_url,
discovered_at: parsed.btcpay.discovered_at,
});
}
return res.json({ found: false });
} catch (err) {
// No discovery file yet (fresh install hadn't run setup, or
// BTCPay wasn't installed when setup ran). Treat as not found.
return res.json({ found: false });
}
});
// Operator-initiated recovery: scan recent settled BTCPay invoices
// and grant credits for any that weren't processed via the webhook
// (typical cause: broken webhook URL pointing at unreachable
// mDNS). Idempotent. Returns a summary of what got credited.
router.post("/rescan-invoices", async (_req, res) => {
try {
const result = await rescanSettledInvoices();
if (!result.ok) {
return res.status(502).json(result);
}
res.json(result);
} catch (err) {
res.status(500).json({
error: "rescan_failed",
message: (err?.message || String(err)).slice(0, 200),
});
}
});
// Convenience: tells the dashboard whether BTCPay is fully wired
// up, half-set-up, or empty — so the dashboard can show the right
// CTA ("Connect BTCPay" / "Reconnect" / "Connected").
router.get("/status", async (_req, res) => {
const cfg = await getConfigSnapshot();
res.json({
configured: !!(
cfg.relay_btcpay_base_url &&
cfg.relay_btcpay_store_id &&
cfg.relay_btcpay_api_key &&
cfg.relay_btcpay_webhook_secret
),
base_url: cfg.relay_btcpay_base_url || null,
store_id: cfg.relay_btcpay_store_id || null,
has_api_key: !!cfg.relay_btcpay_api_key,
has_webhook_secret: !!cfg.relay_btcpay_webhook_secret,
});
});
return router;
}
// Setup-context is a transient JSON blob holding partial setup state
// (BTCPay URL + API key + permissions) between the authorize callback
// and the finalize step. Lives on disk only while a setup is in
// progress; finalize wipes it.
function setupContextPath(dataDir) {
return path.join(dataDir, "btcpay-setup.json");
}
async function stashSetupContext(dataDir, ctx) {
const file = setupContextPath(dataDir);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, JSON.stringify(ctx), { mode: 0o600 });
}
async function readSetupContext(dataDir) {
try {
const raw = await fs.readFile(setupContextPath(dataDir), "utf8");
return JSON.parse(raw);
} catch {
return null;
}
}
async function clearSetupContext(dataDir) {
try {
await fs.unlink(setupContextPath(dataDir));
} catch {}
}
// Extract the set of unique store IDs the operator selected on
// BTCPay's authorize page. With `selectiveStores=true`, every granted
// permission is suffixed with `:STOREID` — same store ID on every
// entry when the operator picked just one. Returns an array of
// unique store ids (possibly empty if BTCPay didn't supply them).
function extractStoreIdsFromPermissions(permissions) {
if (!Array.isArray(permissions)) return [];
const ids = new Set();
for (const p of permissions) {
if (typeof p !== "string") continue;
const m = p.match(/^btcpay\.store\.[^:]+:(\S+)$/);
if (m && m[1]) ids.add(m[1]);
}
return Array.from(ids);
}
// Shared finalize: create the webhook on BTCPay, write the four
// BTCPay credentials into the live relay config, clear the setup
// context. Used by the explicit POST /finalize endpoint AND by the
// callback's auto-finalize path when only one store was authorized.
//
// Webhook URL needs to be a hostname BTCPay's container can reach
// over the network. Priority:
// 1. Relay's discovered clearnet URL (e.g. https://relay.keysat.xyz)
// — works because BTCPay container has standard internet DNS.
// 2. baseRelayUrl (the Host header from the operator's browser request)
// — fallback for operators with no clearnet URL set up; only works
// if BTCPay happens to be able to resolve that name.
// We never want to use mDNS .local here — BTCPay's container has no
// avahi so .local won't resolve and every webhook delivery fails
// silently, which is exactly the bug that caused paid invoices to
// not credit the install.
//
// Before creating the new webhook we GET + DELETE any existing
// webhooks on this store pointing at /relay/btcpay/webhook — keeps
// the dashboard's Reconnect button idempotent and prevents BTCPay
// from accumulating dead webhook entries that retry forever.
async function finalizeBtcpay({ ctx, storeId, dataDir, baseRelayUrl }) {
const selfPublicUrl = await readDiscoveredSelfPublicUrl(dataDir);
const webhookBase = (selfPublicUrl || baseRelayUrl).replace(/\/$/, "");
const webhookUrl = `${webhookBase}/relay/btcpay/webhook`;
const webhookSecret = crypto.randomBytes(32).toString("hex");
const apiBase = (ctx.btcpay_internal_url || ctx.btcpay_url).replace(/\/$/, "");
const authHeader = `token ${ctx.api_key}`;
// Delete any existing webhooks on this store that point at our
// webhook path. Otherwise Reconnect leaves the old (possibly
// broken) webhook in place AND adds a new one, and BTCPay sends
// each InvoiceSettled to both.
try {
const listRes = await fetch(
`${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks`,
{ headers: { Authorization: authHeader } }
);
if (listRes.ok) {
const existing = await listRes.json();
if (Array.isArray(existing)) {
for (const w of existing) {
if (typeof w?.url === "string" && /\/relay\/btcpay\/webhook$/i.test(w.url)) {
try {
await fetch(
`${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks/${encodeURIComponent(w.id)}`,
{ method: "DELETE", headers: { Authorization: authHeader } }
);
console.log(`[btcpay/finalize] deleted stale webhook ${w.id} (url=${w.url})`);
} catch (err) {
console.warn(`[btcpay/finalize] failed to delete stale webhook ${w.id}: ${err?.message || err}`);
}
}
}
}
}
} catch (err) {
console.warn(`[btcpay/finalize] webhook cleanup skipped: ${err?.message || err}`);
}
try {
const r = await fetch(
`${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks`,
{
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: webhookUrl,
secret: webhookSecret,
authorizedEvents: { everything: false, specificEvents: ["InvoiceSettled"] },
automaticRedelivery: true,
enabled: true,
}),
}
);
if (!r.ok) {
const text = await r.text();
return {
ok: false,
status: 502,
error: "webhook_create_failed",
message: text.slice(0, 300),
};
}
console.log(`[btcpay/finalize] webhook created: ${webhookUrl}`);
} catch (err) {
return {
ok: false,
status: 502,
error: "webhook_create_failed",
message: (err?.message || String(err)).slice(0, 200),
};
}
const configPath = path.join(dataDir, "config", "relay-config.json");
let existing = {};
try {
existing = JSON.parse(await fs.readFile(configPath, "utf8"));
} catch {}
existing.relay_btcpay_base_url = ctx.btcpay_url;
existing.relay_btcpay_internal_url = ctx.btcpay_internal_url || "";
existing.relay_btcpay_public_url = ctx.btcpay_public_url || "";
existing.relay_btcpay_store_id = storeId;
existing.relay_btcpay_api_key = ctx.api_key;
existing.relay_btcpay_webhook_secret = webhookSecret;
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(existing), { mode: 0o600 });
await clearSetupContext(dataDir);
return { ok: true, webhookUrl };
}
// Pull btcpay.internal_url from the discovered-services.json file
// written by the StartOS setup hook (init/setup.ts). Returns null if
// BTCPay isn't co-installed or discovery hasn't run.
async function readDiscoveredInternalUrl(dataDir) {
try {
const raw = await fs.readFile(
path.join(dataDir, "discovered-services.json"),
"utf8"
);
const parsed = JSON.parse(raw);
return parsed?.btcpay?.internal_url || null;
} catch {
return null;
}
}
// Companion to readDiscoveredInternalUrl: returns the public clearnet
// URL Spark Control discovered for BTCPay. Used to rewrite the
// buyer-facing checkout link.
async function readDiscoveredPublicUrl(dataDir) {
try {
const raw = await fs.readFile(
path.join(dataDir, "discovered-services.json"),
"utf8"
);
const parsed = JSON.parse(raw);
return parsed?.btcpay?.public_url || null;
} catch {
return null;
}
}
// The relay's OWN clearnet URL (e.g. https://relay.keysat.xyz) —
// captured at install time via sdk.serviceInterface.getAllOwn.
// Used to construct the webhook URL we register with BTCPay so
// BTCPay's container can actually reach it.
async function readDiscoveredSelfPublicUrl(dataDir) {
try {
const raw = await fs.readFile(
path.join(dataDir, "discovered-services.json"),
"utf8"
);
const parsed = JSON.parse(raw);
return parsed?.self?.public_url || null;
} catch {
return null;
}
}
// Reverse-engineer the public-facing URL of the relay from the
// incoming request. Used to build the callback + webhook URLs without
// having to ask the operator to type their own hostname in twice.
function absoluteRelayUrl(req) {
const proto =
(req.headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
req.protocol ||
"http";
const host = req.headers["x-forwarded-host"] || req.headers.host;
return `${proto}://${host}`;
}
// Minimal HTML wrapper for the picker page. Matches the dashboard's
// dark palette so the operator doesn't feel like they've fallen off
// the edge of the world.
function html(title, body) {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<title>${title}</title>
<style>
body { background:#0f172a; color:#e2e8f0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; margin:0; padding:40px; }
.wrap { max-width:560px; margin:0 auto; }
h1 { font-size:22px; margin:0 0 16px; }
a { color:#a5b4fc; }
button { font-family:inherit; }
</style></head><body><div class="wrap">${body}</div></body></html>`;
}
+748
View File
@@ -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();
}
}
+394
View File
@@ -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;
}
+135
View File
@@ -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;
}