d0e98424c1
- Arbitrary file write (P0): validate import keys in /api/library/import via a now-exported safeFilename(); a ../../ key is skipped, not written out of the scope dir. - SSRF (P0): guard downloadPodcastAudio — reject non-HTTP(S) schemes, block IP-literal and DNS-resolved private/link-local/loopback/reserved/multicast and embedded-IPv4 IPv6 targets (closes DNS rebinding), cap + resolve redirects. - ESM require (P1): top-level import of randomBytes in license-purchase.js (the inner require threw on the anon purchase-settle path). - Concurrency lock (P1): skip the process-global free-tier slot in multi-mode so it no longer serializes every cloud tenant onto one job. - X-Forwarded-For bypass (P1): set Express trust proxy from RECAP_TRUSTED_PROXY_HOPS (default 1); getClientIp now reads req.ip instead of a client-spoofable XFF entry. Tests added for safeFilename, the SSRF guard, and getClientIp (119 pass). Registry blockers deferred (ROADMAP); leaked-key history purge queued.
424 lines
18 KiB
JavaScript
424 lines
18 KiB
JavaScript
// 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";
|
|
import { randomBytes } from "crypto";
|
|
|
|
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.
|
|
function randomUuid() {
|
|
// Same crypto.randomBytes(16).toString("hex") pattern used elsewhere.
|
|
return randomBytes(16).toString("hex");
|
|
}
|