Files
recap/server/credits-purchase.js
Keysat 0ae59f3550 Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
2026-06-13 14:25:05 -05:00

672 lines
26 KiB
JavaScript

// Recap-side proxy to the relay's credit-purchase endpoints.
//
// Architecture is identical to license-purchase.js: Recap doesn't
// hold the BTCPay credentials, the relay does. Recap just forwards
// the buyer's pick to the relay and proxies the polling. The relay
// returns the BTCPay checkout URL which the Recap UI displays in a
// modal styled to match the license-purchase modal.
//
// Endpoints:
// GET /api/credits/packages → relay GET /relay/credits/packages
// POST /api/credits/buy → relay POST /relay/credits/buy
// GET /api/credits/invoice/:id → relay GET /relay/credits/invoice/:id
//
// Auth headers (X-Recap-Install-Id + Authorization Bearer LIC1-...)
// are added by this proxy, not by the buyer-side JS — keeping the
// install identity + license key out of any client-side code.
import { getRelayBaseURL } from "./relay-default.js";
import { getInstallId } from "./install-id.js";
import { getRawLicenseKey } from "./license.js";
// Multi-mode toggle. In multi mode every credit purchase is recorded
// in pending_purchases so we know WHO (signed-in user vs. anon trial
// cookie) to credit locally when the invoice settles. Single mode is
// the legacy "operator-pool only" flow — no local accounting layer,
// the relay's credits.json IS the source of truth.
const RECAP_MODE = process.env.RECAP_MODE === "multi" ? "multi" : "single";
function relayHeaders({ json = false, req = null } = {}) {
const h = {};
// Identity routing for the credit-purchase + credit-poll flow:
//
// Pro/Max signed-in tenant (req.user.keysat_license set)
// → use THEIR install ID + license. The buy invoice gets
// stashed with THEIR license_fingerprint so the BTCPay
// settle-webhook credits THEIR license-keyed pool — the
// same pool /api/relay/status reads when displaying their
// balance. Without this, credits land on the operator's
// pool and the buyer sees their balance unchanged after
// paying (the bug Grant hit on 2026-05-18).
//
// Anon visitor (trial cookie only) / free signed-in tenant
// (no license) / single-mode operator
// → fall back to operator identity. The operator's pool is
// what's being topped up; Recaps' own accounting layer
// (anon_trials / tenant_credits) handles the per-user
// attribution locally via pending_purchases.
let installId = null;
let licenseKey = null;
if (req?.user?.keysat_license && req.user.synthetic_install_id) {
installId = req.user.synthetic_install_id;
licenseKey = req.user.keysat_license;
}
if (!installId) {
try {
const id = getInstallId();
if (id) installId = id;
} catch {}
}
if (!licenseKey) {
try {
const key = getRawLicenseKey();
if (key) licenseKey = key;
} catch {}
}
if (installId) h["X-Recap-Install-Id"] = installId;
if (licenseKey) h["Authorization"] = `Bearer ${licenseKey}`;
if (json) h["Content-Type"] = "application/json";
return h;
}
export function setupCreditsPurchaseRoutes(app) {
// List bundles the operator has configured. Cheap, no auth gating —
// the buyer needs the price menu before they decide whether to pay.
app.get("/api/credits/packages", async (_req, res) => {
const base = getRelayBaseURL();
if (!base) {
return res.status(503).json({
error: "relay_not_configured",
message: "Relay base URL not set on this Recaps install.",
});
}
try {
// 10s timeout — was 5s, but a cold relay request from mobile
// cellular can take 6-8s, and Safari iOS surfaces the abort as
// a generic "Load failed" with no other info, so the buyer
// sees an error and has to manually retry. 10s is still snappy
// enough that a legit failure doesn't hang the UI for long.
const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/packages`, {
signal: AbortSignal.timeout(10000),
});
const text = await r.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {}
if (!r.ok) {
return res.status(r.status).json(body || { error: "relay_packages_failed" });
}
res.json(body || { packages: [] });
} catch (err) {
console.error(`[credits/packages] failed: ${err?.message || err}`);
res.status(502).json({
error: "packages_fetch_failed",
message: (err?.message || String(err)).slice(0, 300),
});
}
});
// Initiate a purchase. Body: { credits: 1|5|10|20 }. Returns the
// raw relay envelope (so the UI sees credits_remaining + tier +
// result.checkout_url + result.invoice_id).
//
// Multi-mode: identifies the buyer (signed-in user or anon trial
// cookie), records a pending_purchases row keyed by the invoice_id
// the relay returns. The settle-handler (in /api/credits/invoice/:id
// below) uses that row to know WHERE to apply the credits locally.
//
// Single-mode: skips the pending_purchases bookkeeping entirely;
// the operator IS the buyer and the relay's credits.json directly
// tracks their pool.
app.post("/api/credits/buy", async (req, res) => {
const base = getRelayBaseURL();
if (!base) {
return res
.status(503)
.json({ error: "relay_not_configured" });
}
const credits = Number(req.body?.credits);
const returnUrl =
typeof req.body?.return_url === "string" && req.body.return_url.startsWith("http")
? req.body.return_url
: null;
if (!Number.isFinite(credits) || credits <= 0) {
return res
.status(400)
.json({ error: "credits_required" });
}
// Identify the buyer for multi-mode. Either a signed-in user OR
// an anon trial cookie. If neither, attempt to auto-mint a trial
// cookie — anon visitors who click "Buy more" from the toolbar
// (before they've spent their pre-trial allowance) shouldn't be
// dead-ended into a sign-in nag. Same auto-mint pattern as
// /api/process for pre-trial visitors. Only refuse if trials are
// disabled or the IP is over its lifetime cap.
let buyerType = null;
let buyerId = null;
if (RECAP_MODE === "multi") {
if (req.user && req.user.id) {
buyerType = "user";
buyerId = req.user.id;
} else if (req.trial && req.trial.cookie_id) {
buyerType = "anon";
buyerId = req.trial.cookie_id;
} else {
// Try to mint a fresh trial cookie so the purchase has
// somewhere to land. forceMint=true bypasses the lifetime
// IP cap and the trials-disabled config — a paying buyer is
// by definition not abusing a free quota, and without a
// tracking cookie the settle handler has nowhere to credit
// the purchase locally (the relay still credits the operator
// pool; we just lose the visibility to apply it to this
// specific buyer).
try {
const { issueIfEligible } = await import("./anon-trial.js");
const trial = await issueIfEligible({ req, res, forceMint: true });
if (trial) {
buyerType = "anon";
buyerId = trial.cookie_id;
// Stash on req for downstream code paths
req.trial = trial;
}
} catch (err) {
console.warn(
"[credits/buy] anon-trial mint failed:",
err?.message || err,
);
}
if (!buyerId) {
return res.status(401).json({
error: "buyer_unknown",
message:
"Couldn't create a buyer record for this purchase. Sign up for a free account so we have somewhere to credit it.",
});
}
}
}
try {
const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/buy`, {
method: "POST",
headers: relayHeaders({ json: true, req }),
body: JSON.stringify({ credits, return_url: returnUrl || undefined }),
signal: AbortSignal.timeout(15_000),
});
const text = await r.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {}
if (!r.ok) {
return res
.status(r.status)
.json(body || { error: "relay_buy_failed" });
}
// Record the pending purchase BEFORE we respond, so even if the
// browser refreshes / crashes between buy + settle, the next
// poll for this invoice id will still know who to credit.
// Invoice id lives under result.invoice_id per the relay's
// envelope contract (same shape license-purchase uses).
const invoiceId =
body?.result?.invoice_id ||
body?.invoice_id ||
body?.btcpay_invoice_id ||
null;
if (RECAP_MODE === "multi") {
if (!invoiceId) {
// Loud warning — without an invoice id we can't reconcile
// on settle. Surface the response shape so we can see what
// the relay actually returned and fix the field-name
// assumption if this fires.
console.warn(
`[credits/buy] NO invoice_id in relay response — skipping pending_purchases. Top-level keys: ${Object.keys(body || {}).join(", ")} | result keys: ${Object.keys(body?.result || {}).join(", ")}`,
);
} else if (!buyerType || !buyerId) {
console.warn(
`[credits/buy] invoice ${invoiceId}: buyer identity missing — won't auto-apply on settle.`,
);
} else {
try {
const { getDb } = await import("./db.js");
const result = getDb()
.prepare(
`INSERT OR IGNORE INTO pending_purchases
(invoice_id, buyer_type, buyer_id, credits, created_at)
VALUES (?, ?, ?, ?, ?)`,
)
.run(invoiceId, buyerType, buyerId, credits, Date.now());
console.log(
`[credits/buy] tracked pending purchase invoice=${invoiceId} buyer=${buyerType}:${buyerId} credits=${credits} rowsInserted=${result.changes}`,
);
} catch (err) {
console.error(
`[credits/buy] failed to record pending purchase ${invoiceId}: ${err?.message || err}`,
);
}
}
}
res.json(body || {});
} catch (err) {
console.error(`[credits/buy] failed: ${err?.message || err}`);
res.status(502).json({
error: "purchase_failed",
message: (err?.message || String(err)).slice(0, 300),
});
}
});
// Poll an invoice's status. Returns the relay envelope; the UI
// reads `result.status` ("new" | "processing" | "settled" |
// "expired" | "invalid") and refreshes when settled.
//
// Multi-mode side effect: when the relay reports settled, we look
// up the matching pending_purchases row and apply the credits to
// the right local balance. Idempotent via applied_at — if the same
// invoice is polled multiple times after settle, only the first
// application takes effect.
app.get("/api/credits/invoice/:id", async (req, res) => {
const base = getRelayBaseURL();
if (!base) {
return res.status(503).json({ error: "relay_not_configured" });
}
const id = (req.params.id || "").trim();
if (!id) {
return res.status(400).json({ error: "missing_invoice_id" });
}
try {
const r = await fetch(
`${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(id)}`,
{
headers: relayHeaders({ req }),
signal: AbortSignal.timeout(10_000),
}
);
const text = await r.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {}
if (!r.ok) {
return res
.status(r.status)
.json(body || { error: "relay_poll_failed" });
}
// Multi-mode: settle-and-apply. Status path mirrors the
// license-purchase poll-settle handler.
const status =
body?.result?.status || body?.status || null;
if (RECAP_MODE === "multi" && status === "settled") {
try {
await applyPendingPurchase(id);
} catch (err) {
console.error(
`[credits/invoice] apply failed for ${id}: ${err?.message || err}`,
);
// Don't fail the response — the relay reported settled and
// the operator pool has the credits. Local apply can be
// retried by hitting this endpoint again, or by a future
// reconciliation tool.
}
}
res.json(body || {});
} catch (err) {
console.error(`[credits/invoice] failed: ${err?.message || err}`);
res.status(502).json({
error: "poll_failed",
message: (err?.message || String(err)).slice(0, 300),
});
}
});
// POST /api/credits/claim { invoice_id }
// Manual self-service recovery: a signed-in user pastes the BTCPay
// invoice ID of a purchase they made anonymously (e.g., Safari
// Private mode where the trial cookie didn't survive the magic-
// link click). We verify the invoice is settled at the relay AND
// the pending_purchases row is anon-buyer + unapplied, then credit
// their account.
//
// Safety:
// - Requires authenticated user (req.user.id must be set)
// - Only claims buyer_type='anon' rows (no user-to-user takeover)
// - applied_at idempotency guard prevents double-credit
// - BTCPay invoice IDs are 30+ char random — not enumerable
// - User-buyer rows are never claimable here, regardless of
// ownership — those are the cookie sweep's job
app.post("/api/credits/claim", async (req, res) => {
if (RECAP_MODE !== "multi") {
return res.status(404).json({ error: "not_available" });
}
if (!req.user || !req.user.id) {
return res.status(401).json({
error: "auth_required",
message: "Sign in first to claim a purchase to your account.",
});
}
const invoiceId = String(req.body?.invoice_id || "").trim();
if (!invoiceId) {
return res.status(400).json({
error: "missing_invoice_id",
message: "Paste the invoice ID from your purchase email.",
});
}
const { getDb } = await import("./db.js");
const db = getDb();
const row = db
.prepare(
`SELECT invoice_id, buyer_type, buyer_id, credits, applied_at
FROM pending_purchases WHERE invoice_id = ?`,
)
.get(invoiceId);
if (!row) {
return res.status(404).json({
error: "invoice_not_found",
message:
"We don't have a record of that invoice ID. Double-check it — the ID is shown in your BTCPay payment confirmation.",
});
}
if (row.buyer_type !== "anon") {
// user-buyer rows are claimable only by their original buyer
// (cookie sweep) — refusing this avoids user-to-user takeover.
return res.status(403).json({
error: "not_anon_purchase",
message:
"This invoice was bought from a signed-in account and can only be claimed by that account.",
});
}
if (row.applied_at) {
return res.status(409).json({
error: "already_applied",
message:
"Those credits were already applied. Check your balance — they may have transferred automatically.",
});
}
// Verify settled at the relay before crediting. We do NOT trust
// the local row alone — the buyer could have initiated the
// invoice and never paid; without this check, anyone could
// claim N credits just by knowing an invoice ID.
const base = getRelayBaseURL();
if (!base) {
return res.status(503).json({ error: "relay_not_configured" });
}
let status = null;
try {
const r = await fetch(
`${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`,
{ headers: relayHeaders({ req }), signal: AbortSignal.timeout(10_000) },
);
if (r.ok) {
const body = await r.json().catch(() => ({}));
status = body?.result?.status || body?.status || null;
}
} catch (err) {
console.warn(
`[credits/claim] relay status check failed for ${invoiceId}: ${err?.message || err}`,
);
return res.status(502).json({
error: "relay_unreachable",
message:
"Couldn't verify the invoice with the payment server. Try again in a minute.",
});
}
if (status !== "settled") {
return res.status(409).json({
error: "not_settled",
message: `That invoice is not settled (status: ${status || "unknown"}). If you just paid, wait a minute and try again.`,
});
}
try {
await applyPendingPurchase(invoiceId, { forceUserId: req.user.id });
} catch (err) {
console.error(
`[credits/claim] apply failed for ${invoiceId}: ${err?.message || err}`,
);
return res.status(500).json({
error: "apply_failed",
message: "Something went wrong applying the credits. Try again.",
});
}
console.log(
`[credits/claim] user ${req.user.id} claimed invoice ${invoiceId} (${row.credits} credits)`,
);
res.json({ ok: true, credits: row.credits });
});
}
// applyPendingPurchase(invoiceId, opts?) — credit the buyer's local
// balance for a settled invoice. Idempotent: bails if the row is
// already marked applied. If the buyer was an anon trial that has
// since been converted to a real user, credits route to the user
// instead.
//
// opts.forceUserId (optional) — route credits to this user instead
// of the row's recorded buyer. Used by the manual-claim endpoint:
// when a signed-in user pastes a BTCPay invoice ID for an anon
// purchase whose trial cookie was lost (e.g., Safari Private mode
// where the magic-link click landed in a different cookie jar), we
// trust the invoice ID as proof-of-ownership and direct the credits
// to their tenant_credits.
//
// Exported so the sweep helper below — and any future server-side
// flow that wants to reconcile a known-settled invoice — can call it
// without going through the /api/credits/invoice/:id route.
export async function applyPendingPurchase(invoiceId, opts = {}) {
const { getDb } = await import("./db.js");
const db = getDb();
const row = db
.prepare(
`SELECT invoice_id, buyer_type, buyer_id, credits, applied_at
FROM pending_purchases WHERE invoice_id = ?`,
)
.get(invoiceId);
if (!row) {
// Either the buy came from a different Recap instance, or the
// bookkeeping insert in /api/credits/buy failed earlier. Nothing
// to do; the operator pool still has the credits from BTCPay.
// Log so operator can reconcile manually if this fires.
console.warn(
`[credits/invoice] settled invoice ${invoiceId} has NO matching pending_purchases row — local balance NOT auto-applied. The credits ARE in the operator pool at the relay; operator should grant manually to the buyer.`,
);
return;
}
if (row.applied_at) {
return; // already applied, idempotent no-op
}
// Resolve buyer → target user_id (for tenant_credits) or trial
// cookie_id (for anon_trials.credits_total). Anon-buyers who have
// since converted to a real user get their credits routed to the
// user's tenant_credits — that's the cleaner outcome and matches
// the "credits transfer on signup" semantics the design promises.
let targetUserId = null;
let targetCookieId = null;
if (opts.forceUserId) {
targetUserId = opts.forceUserId;
} else if (row.buyer_type === "user") {
targetUserId = row.buyer_id;
} else if (row.buyer_type === "anon") {
const trial = db
.prepare(
"SELECT cookie_id, converted_to_user_id FROM anon_trials WHERE cookie_id = ?",
)
.get(row.buyer_id);
if (trial?.converted_to_user_id) {
targetUserId = trial.converted_to_user_id;
} else {
targetCookieId = row.buyer_id;
}
}
// Apply + mark applied in one transaction so a crash mid-way
// doesn't leave a half-credited buyer. Purchased credits land in
// the PERMANENT bucket (purchased_balance) so they're not wiped on
// the next replenishment refresh.
const tx = db.transaction(() => {
if (targetUserId) {
const existing = db
.prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?")
.get(targetUserId);
if (existing) {
db.prepare(
`UPDATE tenant_credits
SET purchased_balance = purchased_balance + ?,
lifetime_granted = lifetime_granted + ?
WHERE user_id = ?`,
).run(row.credits, row.credits, targetUserId);
} else {
db.prepare(
`INSERT INTO tenant_credits
(user_id, purchased_balance, replenish_balance, last_replenish_at,
lifetime_granted, lifetime_consumed)
VALUES (?, ?, 0, ?, ?, 0)`,
).run(targetUserId, row.credits, Date.now(), row.credits);
}
} else if (targetCookieId) {
// Anon trial: credits go into the trial's credits_total (single
// bucket — anons don't have the purchased/replenish split).
// They'll move to purchased_balance on signup via linkToUser.
db.prepare(
`UPDATE anon_trials
SET credits_total = credits_total + ?
WHERE cookie_id = ?`,
).run(row.credits, targetCookieId);
}
db.prepare(
"UPDATE pending_purchases SET applied_at = ? WHERE invoice_id = ?",
).run(Date.now(), invoiceId);
});
tx();
console.log(
`[credits/invoice] applied ${row.credits} credits for ${row.buyer_type}:${row.buyer_id}${
targetUserId ? "user " + targetUserId : "anon " + targetCookieId
}`,
);
}
// sweepUnappliedPurchases({ buyerType, buyerId, cookieIds }) — catch
// up on settled-but-unapplied purchases for a buyer.
//
// Why this exists: the buy → BTCPay → settle → apply pipeline depends
// on the buyer's browser tab polling /api/credits/invoice/:id after
// BTCPay redirects back. But BTCPay redirects in the SAME tab (the
// poll loop dies before it gets a chance to see "settled"), and even
// when the redirect lands back on Recap the buyer might close it
// before the next poll tick. Result: the relay knows the invoice is
// settled and the operator pool has the credits, but the LOCAL
// pending_purchases row never flips to applied — so the buyer's
// balance stays stale until they manually re-poll, which they have no
// way to do.
//
// Fix: opportunistically sweep on every /api/account/whoami and
// /api/relay/status. Cheap (small bounded query + a few relay HTTP
// calls), idempotent (applyPendingPurchase no-ops on already-applied
// rows), and self-healing.
//
// Also called from anon-trial.js linkToUser BEFORE the transfer, so
// any anon-bought credits that hadn't yet been applied locally are
// rolled into anon_trials.credits_total before we copy them over to
// the new user's tenant_credits.
//
// Scope: only sweeps the buyer's OWN pending rows. cookieIds is an
// optional list of additional anon cookie_ids the caller wants
// swept on this buyer's behalf (used by /whoami for the new-signup
// case where the just-converted cookie may still have unapplied
// purchases). Cap at 5 invoices per sweep + 30-minute lookback so a
// degenerate case can't fan out into hundreds of relay calls per
// request.
export async function sweepUnappliedPurchases({
buyerType,
buyerId,
cookieIds = [],
req = null,
} = {}) {
if (RECAP_MODE !== "multi") return;
if (!buyerType && (!cookieIds || cookieIds.length === 0)) return;
const base = getRelayBaseURL();
if (!base) return; // no relay configured, nothing to sweep against
const { getDb } = await import("./db.js");
const db = getDb();
// 30-minute lookback. Older unapplied purchases probably failed for
// a reason we don't want to keep retrying every page-load (relay
// unreachable, invoice expired, etc.). Operator can reconcile
// manually if they fire.
const since = Date.now() - 30 * 60 * 1000;
// Build the WHERE clause. Always include the primary buyer; OR in
// any extra cookieIds the caller passed.
const conditions = [];
const params = [];
if (buyerType && buyerId) {
conditions.push("(buyer_type = ? AND buyer_id = ?)");
params.push(buyerType, buyerId);
}
for (const cid of cookieIds) {
if (typeof cid === "string" && cid) {
conditions.push("(buyer_type = 'anon' AND buyer_id = ?)");
params.push(cid);
}
}
if (conditions.length === 0) return;
params.push(since);
let rows = [];
try {
rows = db
.prepare(
`SELECT invoice_id FROM pending_purchases
WHERE (${conditions.join(" OR ")})
AND applied_at IS NULL
AND created_at >= ?
ORDER BY created_at DESC
LIMIT 5`,
)
.all(...params);
} catch (err) {
console.warn(
`[credits/sweep] query failed: ${err?.message || err}`,
);
return;
}
if (rows.length === 0) return;
for (const { invoice_id: invoiceId } of rows) {
try {
const r = await fetch(
`${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`,
{
headers: relayHeaders({ req }),
signal: AbortSignal.timeout(5_000),
},
);
if (!r.ok) continue;
const text = await r.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {}
const status = body?.result?.status || body?.status || null;
if (status === "settled") {
await applyPendingPurchase(invoiceId);
}
} catch (err) {
// Best-effort; swallow per-invoice errors so one bad invoice
// doesn't block the others (or the page-load).
console.warn(
`[credits/sweep] invoice ${invoiceId} check failed: ${err?.message || err}`,
);
}
}
}