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
This commit is contained in:
Keysat
2026-06-13 14:25:05 -05:00
parent db580abad7
commit 0ae59f3550
176 changed files with 23823 additions and 803 deletions
+252
View File
@@ -0,0 +1,252 @@
// Self-serve subscription purchase (multi-mode / cloud only).
//
// Lets a signed-in cloud user buy their OWN prepaid Pro/Max period
// instead of waiting for the operator to grant it by hand. The relay
// owns the subscription (keyed by Recaps user-id, per the core-
// decoupling); Recaps just brokers the purchase:
//
// POST /api/billing/buy → ask the relay to mint a BTCPay invoice
// for {tier}; return the checkout URL the
// frontend opens. On settlement the relay's
// webhook extends the user's tier.
// GET /api/billing/status → pull the user's current (expiry-enforced)
// tier from the relay and refresh the local
// users.tier cache so the badge flips the
// moment payment lands. The frontend polls
// this after opening checkout.
//
// Auth: both routes require a real signed-in user (req.user.id). Anon /
// trial visitors (req.userId = "anon:<cookie>") are refused — a tier is
// keyed to a durable user-id, which a trial cookie isn't.
//
// These live under /api/billing (NOT /api/subscriptions — that prefix is
// the channel-subscriptions feature, which is itself Pro-gated; a free
// user must be able to reach the buy flow). The prefix is added to the
// license middleware's open list so the activation gate lets Core users
// through to purchase.
import { getDb } from "./db.js";
import { requireUser } from "./tenant-auth.js";
import {
createRelayTierInvoice,
createRelayZapriteOrder,
getRelayUserTier,
getRelayTierPlans,
} from "./providers/relay.js";
const BUYABLE_TIERS = new Set(["pro", "max"]);
const PAYMENT_METHODS = new Set(["bitcoin", "card"]);
// Fallback prices (sats / 30-day period) used only when the relay is
// unreachable while rendering the picker — matches the relay config
// defaults so the UI never shows a blank price. The actual charge is
// always computed relay-side at invoice time.
const FALLBACK_PLANS = {
period_days: 30,
plans: [
{ tier: "pro", sats: 21000 },
{ tier: "max", sats: 42000 },
],
};
// Pull the user's effective (expiry-enforced) tier from the relay — the
// authoritative subscription owner — and update the cached users.tier if
// it drifted. Returns { tier, expires_at, synced } or { synced:false }
// when the relay is unreachable / unconfigured (caller falls back to the
// cached value rather than erroring the request).
export async function syncUserTierFromRelay(userId) {
if (!userId) return { synced: false };
let report;
try {
report = await getRelayUserTier({ userId });
} catch (err) {
console.warn(
`[billing] relay tier read failed for ${userId}: ${err?.message || err}`,
);
return { synced: false };
}
// getRelayUserTier swallows errors and returns null when the relay
// base URL / operator key isn't configured. Treat that as "can't
// sync" rather than "downgrade to core".
if (!report || typeof report.tier !== "string") {
return { synced: false };
}
const tier = report.tier; // already expiry-enforced by the relay
const expiresAt = report.subscription_expires_at || null;
try {
const db = getDb();
const row = db.prepare("SELECT tier FROM users WHERE id = ?").get(userId);
if (row && row.tier !== tier) {
db.prepare("UPDATE users SET tier = ? WHERE id = ?").run(tier, userId);
console.log(
`[billing] synced ${userId} tier ${row.tier || "core"}${tier} from relay`,
);
}
} catch (err) {
console.warn(
`[billing] tier cache update failed for ${userId}: ${err?.message || err}`,
);
}
return {
tier,
expires_at: expiresAt,
tier_snapshot: report.tier_snapshot || tier,
subscription_expired: !!report.subscription_expired,
synced: true,
};
}
// Build the buyer-facing origin so the BTCPay checkout can redirect back
// to the app after settlement. Honors the reverse-proxy forwarding
// headers StartOS sets in front of the service.
function originFor(req) {
const proto =
(req.headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
req.protocol ||
"https";
const host = req.headers["x-forwarded-host"] || req.headers.host || "";
return host ? `${proto}://${host}` : "";
}
export function setupBillingRoutes(app) {
// GET /api/billing/plans → { period_days, plans: [{tier, sats}] }
// Powers the purchase picker. Prices come from the relay (the pricing
// source of truth); falls back to the config defaults if the relay is
// briefly unreachable so the modal still renders.
app.get("/api/billing/plans", requireUser, async (req, res) => {
if (!req.user || !req.user.id) {
return res.status(403).json({ error: "must_be_signed_in" });
}
try {
const data = await getRelayTierPlans();
if (data && Array.isArray(data.plans) && data.plans.length) {
return res.json({
period_days: data.period_days || FALLBACK_PLANS.period_days,
plans: data.plans,
// Whether the card (Zaprite) rail is configured — the UI hides
// the "Pay by card" link when false so it never offers a rail
// that 503s. Bitcoin is always available (the BTCPay rail).
card_available: !!data.card_available,
source: "relay",
});
}
} catch (err) {
console.warn(`[billing] plans read failed: ${err?.message || err}`);
}
return res.json({ ...FALLBACK_PLANS, card_available: false, source: "fallback" });
});
// POST /api/billing/buy body: { tier: "pro" | "max", method?: "bitcoin" | "card" }
// Bitcoin (default) → BTCPay invoice; card → Zaprite hosted checkout.
// Returns { ok, method, checkout_url, tier, period_days, ... }.
app.post("/api/billing/buy", requireUser, async (req, res) => {
// Must be a real signed-in user — a tier is keyed to a durable
// user-id, not an anon trial cookie.
if (!req.user || !req.user.id) {
return res.status(403).json({
error: "must_be_signed_in",
message: "Sign in to buy a subscription.",
});
}
const tier = String(req.body?.tier || "").trim().toLowerCase();
if (!BUYABLE_TIERS.has(tier)) {
return res.status(400).json({
error: "bad_tier",
message: 'tier must be "pro" or "max"',
});
}
const method = String(req.body?.method || "bitcoin").trim().toLowerCase();
if (!PAYMENT_METHODS.has(method)) {
return res.status(400).json({
error: "bad_method",
message: 'method must be "bitcoin" or "card"',
});
}
const origin = originFor(req);
// Land back on the app with a marker the frontend uses to kick an
// immediate status sync (the modal also polls, so this is a courtesy
// for buyers who follow the checkout redirect).
const returnUrl = origin ? `${origin}/?billing=success` : null;
try {
if (method === "card") {
const order = await createRelayZapriteOrder({
userId: req.user.id,
tier,
returnUrl,
});
return res.json({
ok: true,
method: "card",
checkout_url: order.checkout_url || null,
order_id: order.order_id || null,
amount: order.amount ?? null,
currency: order.currency || null,
tier: order.tier || tier,
period_days: order.period_days ?? null,
});
}
// Bitcoin (default) — BTCPay invoice.
const invoice = await createRelayTierInvoice({
userId: req.user.id,
tier,
returnUrl,
});
res.json({
ok: true,
method: "bitcoin",
checkout_url: invoice.checkout_url || null,
invoice_id: invoice.invoice_id || null,
sats: invoice.sats ?? null,
tier: invoice.tier || tier,
period_days: invoice.period_days ?? null,
// Lightning BOLT11 for the inline QR (no redirect). Null → the app
// falls back to opening the hosted checkout_url.
bolt11: invoice.bolt11 || null,
lightning_payment_link: invoice.lightning_payment_link || null,
lightning_expires_at: invoice.lightning_expires_at || null,
});
} catch (err) {
const status = err?.status || 502;
console.error(
`[billing] buy failed for ${req.user.id} (${tier}/${method}): ${err?.message || err}`,
);
// 503 from the relay = that rail isn't configured; surface a hint.
const notConfigured =
status === 503 || /not[_ ]configured/i.test(err?.message || "");
const rail = method === "card" ? "Card" : "Bitcoin";
const tool = method === "card" ? "Zaprite" : "BTCPay";
res.status(notConfigured ? 503 : 502).json({
error: notConfigured ? "payments_unavailable" : "checkout_failed",
message: notConfigured
? `${rail} payments aren't set up on this server yet. Ask the operator to configure ${tool}.`
: "Couldn't start the payment. Please try again in a moment.",
});
}
});
// GET /api/billing/status
// Returns { tier, expires_at, synced } — the user's current relay-owned
// tier, with the local cache refreshed as a side effect.
app.get("/api/billing/status", requireUser, async (req, res) => {
if (!req.user || !req.user.id) {
return res.status(403).json({ error: "must_be_signed_in" });
}
const synced = await syncUserTierFromRelay(req.user.id);
if (synced.synced) {
return res.json({
tier: synced.tier,
expires_at: synced.expires_at,
tier_snapshot: synced.tier_snapshot,
subscription_expired: synced.subscription_expired,
synced: true,
});
}
// Relay unreachable / unconfigured — fall back to the cached tier so
// the UI still renders a sane badge instead of erroring.
return res.json({
tier: req.user.tier || "core",
expires_at: null,
synced: false,
});
});
}