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
+425
View File
@@ -0,0 +1,425 @@
// In-app buy flow. Proxies the Keysat public API (policies + purchase
// + poll) through Recap so the frontend renders tier cards in Recap's
// own visual style instead of being redirected to Keysat's hosted
// `/buy/<slug>` page.
//
// API shape implemented here matches the Keysat spec exactly:
// GET /api/license/policies → listPublicPolicies(PRODUCT_SLUG)
// POST /api/license/purchase → startPurchase(PRODUCT_SLUG, opts)
// GET /api/license/poll/:invoiceId → pollPurchase(invoiceId); on a
// settled invoice this also
// ACTIVATES the issued license
// on this Recap install so the
// next license-status refresh
// sees the new entitlements.
//
// All three routes are open to unlicensed users — buyers need to reach
// them before they have a license, by definition.
import { Client } from "@keysat/licensing-client";
import * as license from "./license.js";
const KEYSAT_BASE_URL = license.KEYSAT_BASE_URL;
const PRODUCT_SLUG = license.PRODUCT_SLUG;
// Multi-mode toggle. In single mode (the default), an activated license
// is written to /data/license.txt — operator-install-wide. In multi
// mode, the buyer is a logged-in user and the license attaches to
// THEIR users row instead. Set at module load; the file is re-imported
// by tests so module-level state isn't an issue.
const RECAP_MODE = process.env.RECAP_MODE === "multi" ? "multi" : "single";
// Lazy-init the SDK client so a missing import doesn't crash boot.
let _client = null;
function getClient() {
if (!_client) _client = new Client(KEYSAT_BASE_URL);
return _client;
}
// Tiny in-memory cache for the policies response. Keysat's admin can
// change tiers any time so don't cache long — 30s strikes the balance
// between "operator edit takes effect quickly" and "don't hammer the
// licensing server on every browser-side modal mount". Pair this
// with using ?refresh=1 on the client if you need to invalidate
// faster than the TTL.
const POLICIES_TTL_MS = 30 * 1000;
let _policiesCache = { at: 0, body: null };
export function setupLicensePurchaseRoutes(app, { onLicenseActivated } = {}) {
// ── List tiers ────────────────────────────────────────────────────────────
// We deliberately bypass the SDK's listPublicPolicies() here because
// it strips fields we need to render the buy page: marketing_bullets,
// marketing_bullets_position, hidden_entitlements, and
// featured_discount. Hitting the public HTTP endpoint directly gives
// us the full snake_case JSON with everything the Keysat hosted /buy
// page renders, so we can match its data parity in Recap's own
// visual style.
app.get("/api/license/policies", async (_req, res) => {
if (_policiesCache.body && Date.now() - _policiesCache.at < POLICIES_TTL_MS) {
return res.json({
keysat_base_url: KEYSAT_BASE_URL,
product_slug: PRODUCT_SLUG,
..._policiesCache.body,
});
}
try {
const url = `${KEYSAT_BASE_URL.replace(/\/$/, "")}/v1/products/${encodeURIComponent(PRODUCT_SLUG)}/policies`;
const r = await fetch(url, {
signal: AbortSignal.timeout(8000),
});
if (!r.ok) {
const body = await r.text().catch(() => "");
throw new Error(`HTTP ${r.status}: ${body.slice(0, 200)}`);
}
const data = await r.json();
_policiesCache = { at: Date.now(), body: data };
res.json({
keysat_base_url: KEYSAT_BASE_URL,
product_slug: PRODUCT_SLUG,
...data,
});
} catch (err) {
console.error(`[license/policies] failed: ${err?.message || err}`);
res.status(502).json({
error: "policies_fetch_failed",
message: (err?.message || String(err)).slice(0, 300),
});
}
});
// ── Start purchase ────────────────────────────────────────────────────────
// Body: { policySlug: string, buyerEmail?: string, code?: string,
// redirectUrl?: string, buyerNote?: string }
// Returns the FULL raw Keysat /v1/purchase response, including
// discount fields: { invoice_id, checkout_url, final_price_sats,
// discount_applied_sats, amount_sats, btcpay_invoice_id, poll_url }.
// We bypass the SDK (which strips final_price_sats /
// discount_applied_sats from PurchaseSession) so the UI can show
// "you saved N sats" before sending the buyer to BTCPay.
//
// Multi-mode anon-signup flow: if there's no signed-in user AND a
// buyer_email is supplied, we treat this as a "create-account-via-
// purchase" flow. We record a pending_signups row keyed by the
// invoice id so the poll-settle handler can create the user + send
// the license-ready magic-link email once payment lands.
app.post("/api/license/purchase", async (req, res) => {
const {
policySlug,
buyerEmail,
code,
redirectUrl,
buyerNote,
} = req.body || {};
if (!policySlug || typeof policySlug !== "string") {
return res.status(400).json({
error: "missing_policy_slug",
message:
"policySlug is required — get it from /api/license/policies, never hardcode.",
});
}
// Anon-signup gate: in multi-mode, if no signed-in user, an email
// is REQUIRED — that's how the settle handler knows who to send
// the "your account is ready" link to. Signed-in users in multi
// mode have their email implicit via req.user.email (still passed
// via buyerEmail prefill from the frontend), so this only blocks
// the truly-anon-no-email edge case.
const isAnonSignup =
RECAP_MODE === "multi" && !req.user && !!buyerEmail;
if (RECAP_MODE === "multi" && !req.user && !buyerEmail) {
return res.status(400).json({
error: "email_required",
message:
"Enter your email — we'll send a sign-in link once your payment confirms.",
});
}
try {
const url = `${KEYSAT_BASE_URL.replace(/\/$/, "")}/v1/purchase`;
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// Keysat's /v1/purchase expects `product` (NOT `product_slug`,
// despite what its own developer-facing instructions show).
// The SDK confirms this — see vendor/keysat-licensing-client
// dist/index.js, startPurchase POST body.
product: PRODUCT_SLUG,
policy_slug: policySlug,
buyer_email: buyerEmail || undefined,
code: code || undefined,
redirect_url: redirectUrl || undefined,
buyer_note: buyerNote || undefined,
}),
signal: AbortSignal.timeout(15000),
});
const text = await r.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch {}
if (!r.ok) {
return res.status(r.status === 400 ? 400 : 502).json({
error:
(body && (body.error || body.code)) || "purchase_failed",
message:
(body && (body.message || body.detail)) ||
text?.slice(0, 300) ||
`HTTP ${r.status}`,
});
}
// Stash pending_signups row BEFORE responding so a browser
// crash between buy + settle doesn't lose the buyer's email.
// Keysat's response shape: invoice_id at the root.
const invoiceId = body?.invoice_id || null;
if (isAnonSignup && invoiceId) {
try {
const { getDb } = await import("./db.js");
getDb()
.prepare(
`INSERT OR IGNORE INTO pending_signups
(invoice_id, email, policy_slug, created_at)
VALUES (?, ?, ?, ?)`,
)
.run(
invoiceId,
buyerEmail.trim().toLowerCase(),
policySlug,
Date.now(),
);
console.log(
`[license/purchase] tracked pending signup ${invoiceId}${buyerEmail} (${policySlug})`,
);
} catch (err) {
// Non-fatal — the BTCPay invoice still goes through. If we
// can't apply on settle, the operator can manually look up
// the Keysat invoice + activate the license + email the
// buyer.
console.error(
`[license/purchase] failed to record pending signup ${invoiceId}: ${err?.message || err}`,
);
}
}
// Pass the raw response straight through — the frontend reads
// final_price_sats + discount_applied_sats off it to render the
// discount preview before opening the checkout.
res.json(body || {});
} catch (err) {
console.error(`[license/purchase] failed: ${err?.message || err}`);
res.status(502).json({
error: "purchase_failed",
message: (err?.message || String(err)).slice(0, 300),
});
}
});
// ── Poll for license issuance ─────────────────────────────────────────────
// While the BTCPay invoice is pending, status="pending" and no
// licenseKey. Once paid + signed, status="settled" and the response
// carries the LIC1-... key. On settle, we ACTIVATE the key on this
// install (writes to /data/license.txt, refreshes module-scoped LIC
// state) so the next license-status fetch reflects the new
// entitlements. The frontend just calls /api/license-status after
// this returns settled.
app.get("/api/license/poll/:invoiceId", async (req, res) => {
const invoiceId = (req.params.invoiceId || "").trim();
if (!invoiceId) {
return res.status(400).json({ error: "missing_invoice_id" });
}
try {
const poll = await getClient().pollPurchase(invoiceId);
if (
poll.status === "settled" &&
typeof poll.licenseKey === "string" &&
poll.licenseKey.startsWith("LIC1-")
) {
// Settle path forks on mode + buyer identity:
// - single mode: write the key to /data/license.txt so the
// OPERATOR install is now licensed (existing behavior).
// - multi mode + signed-in buyer: attach the key to the
// buyer's user row (users.keysat_license). The next
// resolveProviderOpts() call for that user picks up the
// new license automatically (req.user is re-read
// per-request, so the change is immediate after the
// next signed-in request hits the server).
// - multi mode + anon buyer with pending_signups row:
// create-or-match a user by buyer_email, attach the
// license, send a magic-link email so they can sign in.
// Idempotent via pending_signups.applied_at.
// - multi mode + anon buyer with NO pending row: fall back
// to the single-mode path (write to /data/license.txt).
// This is defensive — shouldn't happen in normal flow.
try {
let routedToUser = false;
if (RECAP_MODE === "multi" && req?.user?.id) {
const { getDb } = await import("./db.js");
getDb()
.prepare("UPDATE users SET keysat_license = ? WHERE id = ?")
.run(poll.licenseKey, req.user.id);
// Mutate the in-memory req.user so subsequent calls in
// the same request see the new license. (next request
// re-reads from DB so this is belt-and-suspenders.)
req.user.keysat_license = poll.licenseKey;
routedToUser = true;
console.log(
`[license/poll] attached license to user ${req.user.id}`,
);
} else if (RECAP_MODE === "multi") {
const applied = await maybeApplyPendingSignup(
invoiceId,
poll.licenseKey,
req,
);
if (applied) routedToUser = true;
}
if (!routedToUser) {
license.activate(poll.licenseKey);
if (typeof onLicenseActivated === "function") {
await onLicenseActivated();
}
}
} catch (e) {
console.error(
`[license/poll] activate-on-settle failed: ${e?.message || e}`
);
// Don't fail the response — the buyer's checkout succeeded,
// they shouldn't see a 500. They can hit "I have a key" and
// paste the key manually as a fallback (or, in multi mode,
// operator can run an admin SQL update).
}
}
res.json(poll);
} catch (err) {
console.error(`[license/poll] failed: ${err?.message || err}`);
res.status(502).json({
error: "poll_failed",
message: (err?.message || String(err)).slice(0, 300),
});
}
});
}
// maybeApplyPendingSignup(invoiceId, licenseKey, req) — multi-mode
// anon-signup settle path. Looks up the pending_signups row for this
// invoice and, if not already applied:
// 1. Create or match a user by buyer email (lowercased)
// 2. Attach the issued license to users.keysat_license
// 3. Send a "your Recap [Pro|Max] account is ready" magic-link email
// 4. Mark applied_at = now
// Returns true if applied, false if no pending row or already applied.
//
// Idempotent: a duplicate poll after settle is a no-op because
// applied_at is the guard.
async function maybeApplyPendingSignup(invoiceId, licenseKey, req) {
const { getDb } = await import("./db.js");
const db = getDb();
const pending = db
.prepare(
`SELECT invoice_id, email, policy_slug, applied_at
FROM pending_signups WHERE invoice_id = ?`,
)
.get(invoiceId);
if (!pending) return false;
if (pending.applied_at) return true; // already applied
const email = pending.email;
const policySlug = pending.policy_slug;
// Parse the license to derive the tier label (for the email copy).
// Falls back to "Pro" if parsing fails — same default as the UI.
let tierLabel = "Pro";
try {
const { parseLicenseKey } = await import("./license.js");
const parsed = parseLicenseKey(licenseKey);
if (parsed && parsed.entitlements) {
if (parsed.entitlements.has("max")) tierLabel = "Max";
else if (parsed.entitlements.has("pro")) tierLabel = "Pro";
}
} catch {}
// Create-or-match user. If a user with this email already exists,
// attach the license to their account (case 'a' from the design
// doc: "friendliest — handles the 'I forgot I had an account' case").
// Otherwise insert a new stub user.
let user = db
.prepare("SELECT id, email, keysat_license FROM users WHERE email = ?")
.get(email);
const now = Date.now();
const tx = db.transaction(() => {
let userId;
if (user) {
userId = user.id;
db.prepare(
"UPDATE users SET keysat_license = ?, last_signin_at = ? WHERE id = ?",
).run(licenseKey, now, userId);
} else {
userId = randomUuid();
const syntheticInstallId = randomUuid();
db.prepare(
`INSERT INTO users
(id, email, created_at, last_signin_at, synthetic_install_id,
keysat_license, is_admin)
VALUES (?, ?, ?, ?, ?, ?, 0)`,
).run(userId, email, now, now, syntheticInstallId, licenseKey);
console.log(
`[license/poll] anon-signup created user ${userId} <${email}> with ${tierLabel} license`,
);
}
db.prepare(
"UPDATE pending_signups SET applied_at = ? WHERE invoice_id = ?",
).run(now, invoiceId);
return userId;
});
const userId = tx();
// Send the "your account is ready" magic-link email. Best-effort —
// if SMTP fails, the user can hit /auth.html and request a regular
// sign-in link (their account now exists with the license attached).
try {
const { sendSignInLink } = await import("./auth-routes.js");
const { renderLicenseReadyEmail } = await import(
"./license-ready-email.js"
);
// Build the email body using the verifyUrl that sendSignInLink
// will produce. We pass emailBody to override the default sign-in
// copy with the celebratory purchase-confirmation framing.
// sendSignInLink generates verifyUrl internally and the body uses
// a placeholder we string-substitute below — but actually
// sendSignInLink already accepts emailBody. We need to construct
// it with the verifyUrl already inserted. So we'll pre-render
// emailBody with verifyUrl set to a token-placeholder string,
// BUT sendSignInLink doesn't expose verifyUrl... Use a small
// bridge: render emailBody after sendSignInLink runs by passing
// a callback. Simplest: just send the link via auth-routes'
// existing helper which has the right copy via emailBody override.
//
// Actually sendSignInLink computes verifyUrl after the token is
// generated. To inject custom copy, the helper needs verifyUrl
// in scope. We pass emailBody as a FUNCTION that receives the
// verifyUrl and returns the {subject,text,html} tuple — but the
// current signature accepts an object. Refactor in v0.2.94 if
// needed. For now the helper accepts either object or function.
await sendSignInLink({
email,
intent: "license_purchase",
emailBody: (verifyUrl) =>
renderLicenseReadyEmail({
verifyUrl,
tierLabel,
brandName: "Recaps",
expiresInMinutes: 15,
}),
});
} catch (err) {
console.error(
`[license/poll] license-ready email send failed for ${email}: ${err?.message || err}`,
);
}
return true;
}
// Local UUID helper — same shape we use in auth-routes for new users.
// Avoids a hard import dep just for this one call.
function randomUuid() {
// Same crypto.randomBytes(16).toString("hex") pattern used elsewhere.
// eslint-disable-next-line global-require
const { randomBytes } = require("crypto");
return randomBytes(16).toString("hex");
}