// 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/` 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"); }