// License gate, Pro-tier feature gates, license routes, and the free- // tier concurrency lock. All license-aware request-handling lives here. // // LIC is exported as a `let` binding (ESM live binding) — importers // reading it get the current value. The activate / deactivate routes // and refreshLicenseOnline mutate it inside the module. import * as license from "./license.js"; // ── Module state ──────────────────────────────────────────────────────────── // LIC is the in-memory snapshot of the current license state, refreshed // on startup, periodically against the licensing server, and after each // activate / deactivate. export let LIC = license.checkLicense(); console.log( `[license] state=${LIC.state} entitlements=[${[...LIC.entitlements].join(",")}]` + (LIC.reason ? ` reason=${LIC.reason}` : "") ); // Free-tier concurrency lock. Unlicensed users may process one video at // a time — second submission while another is in flight returns 409 from // /api/process. The /api/process handler calls tryAcquireFreeSlot() at // entry and releaseFreeSlot() in its finally block. let freeJobInFlight = false; // ── Online validation tunables ────────────────────────────────────────────── const VALIDATE_INTERVAL_MS = parseInt( process.env.RECAP_VALIDATE_INTERVAL_MS || String(6 * 60 * 60 * 1000), 10 ); const ACTIVATE_VALIDATE_TIMEOUT_MS = 8000; // How often to re-read the license file (fast path for keys set via the // StartOS action — the 6 h online cycle is too slow for that UX). const LICENSE_FILE_POLL_MS = parseInt( process.env.RECAP_LICENSE_FILE_POLL_MS || "30000", 10 ); // ── Online refresh ────────────────────────────────────────────────────────── // Calls the licensing server (with the network-error grace logic in // license.validateOnline) and updates LIC. Logs only on state/reason // changes to keep a clean log on healthy machines. export async function refreshLicenseOnline(reason) { const prev = LIC; try { LIC = await license.validateOnline(); } catch (e) { console.error(`[license] refresh threw (${reason}):`, e?.message || e); return; } if (LIC.state !== prev.state || LIC.reason !== prev.reason) { console.log( `[license] refresh (${reason}): state=${LIC.state}` + (LIC.reason ? ` reason=${LIC.reason}` : "") + ` entitlements=[${[...LIC.entitlements].join(",")}]` ); } } // Kick off a startup refresh and the periodic poll. Async startup so the // server doesn't block on a slow Keysat round-trip. export function startLicenseRefresh() { refreshLicenseOnline("startup").catch(() => {}); setInterval(() => { refreshLicenseOnline("scheduled").catch(() => {}); }, VALIDATE_INTERVAL_MS); // Faster offline-only re-read so a license set via the "Set Recap // License" StartOS action (or a manual edit to license.txt) is picked // up within seconds instead of 6 h. Calls checkLicense() rather than // validateOnline() to avoid hammering Keysat — the next scheduled // validateOnline tick will confirm with the server. If a fresh key // appears, kick an immediate online check too so an unrevoked Pro // license doesn't get stuck pending until the 6 h tick. setInterval(() => { const prev = LIC; const next = license.checkLicense(); if (next.licenseId !== prev.licenseId || next.state !== prev.state) { LIC = next; console.log( `[license] file refresh: state=${LIC.state}` + (LIC.reason ? ` reason=${LIC.reason}` : "") + ` entitlements=[${[...LIC.entitlements].join(",")}]` ); if (next.state === "licensed") { refreshLicenseOnline("file change").catch(() => {}); } } }, LICENSE_FILE_POLL_MS); } // ── Free-tier slot management ─────────────────────────────────────────────── // Whether the current LIC counts as a "free" (unlicensed / no core) user. export function isFreeUser() { return !(LIC.state === "licensed" && LIC.entitlements.has("core")); } // Returns true if the slot was acquired, false if another free job is in // flight. The /api/process handler must release via releaseFreeSlot() // in a finally block on every exit path. export function tryAcquireFreeSlot() { if (freeJobInFlight) return false; freeJobInFlight = true; return true; } export function releaseFreeSlot() { freeJobInFlight = false; } // ── Endpoints reachable without a license ─────────────────────────────────── // /api/process is open so unlicensed (free-tier) users can summarize one // video at a time with their own Gemini key. The route handler enforces // BYO-key + the concurrency lock for free users. const LICENSE_OPEN_PATHS = new Set([ "/api/health", "/api/heartbeat", "/api/status", "/api/license-status", "/api/license/activate", "/api/license/deactivate", "/api/process", ]); // ── Pro-tier feature gates ────────────────────────────────────────────────── // Each entry maps URL prefixes → required entitlement; first match wins. // A licensed user without the right entitlement gets a clean 402 // feature_not_in_tier (vs. the generic activation gate above). const PRO_FEATURE_GATES = [ { prefixes: ["/api/subscriptions", "/api/auto-queue", "/api/sub-check-log"], entitlement: "subscriptions", feature: "subscriptions", message: "Channel subscriptions and auto-queue require a Pro license. Upgrade to unlock.", }, { prefixes: ["/api/history"], entitlement: "history", feature: "history", message: "Summary history requires a Pro license. Upgrade to unlock.", }, { prefixes: ["/api/library"], entitlement: "library", feature: "library", message: "Library import/export requires a Pro license. Upgrade to unlock.", }, ]; // ── Middleware setup ──────────────────────────────────────────────────────── // Registers the activation gate + Pro-tier gates on the given Express // app. Order matters — both must be in the chain BEFORE any /api/* // route registration, so call this early in boot. export function setupLicenseMiddleware(app) { // Activation-screen gate: any /api/* request without a valid license is // rejected with 402, except the allowlist above. Non-/api requests // (the static frontend, /assets, etc.) pass through so the UI can load. app.use((req, res, next) => { if (!req.path.startsWith("/api/")) return next(); if (LICENSE_OPEN_PATHS.has(req.path)) return next(); if (LIC.state === "licensed" && LIC.entitlements.has("core")) return next(); return res.status(402).json({ error: "license_required", message: LIC.state === "licensed" ? "Your license is missing the 'core' entitlement. Contact the seller." : "This feature requires a Recap license. Upgrade to unlock.", state: LIC.state, reason: LIC.reason, activate_url: "/#activate", keysat_base_url: license.KEYSAT_BASE_URL, product_slug: license.PRODUCT_SLUG, }); }); // Pro-tier feature gates run after the activation gate. app.use((req, res, next) => { for (const gate of PRO_FEATURE_GATES) { if (gate.prefixes.some((p) => req.path.startsWith(p))) { if (LIC.entitlements.has(gate.entitlement)) return next(); return res.status(402).json({ error: "feature_not_in_tier", feature: gate.feature, message: gate.message, keysat_base_url: license.KEYSAT_BASE_URL, product_slug: license.PRODUCT_SLUG, }); } } next(); }); } // ── License management endpoints ──────────────────────────────────────────── // Open by virtue of being in LICENSE_OPEN_PATHS — the gate lets them // through unauthenticated. export function setupLicenseRoutes(app) { app.get("/api/license-status", (_req, res) => { res.json(license.publicView(LIC)); }); app.post("/api/license/activate", async (req, res) => { try { LIC = license.activate(req.body && req.body.license_key); } catch (e) { if (e && e.code === "bad_format") { return res.status(400).json({ error: "bad_format", message: "Expected a license key starting with 'LIC1-'.", }); } return res.status(500).json({ error: "activation_failed", message: e?.message }); } if (LIC.state !== "licensed") { // Offline signature check failed — no point hitting the server. return res.status(400).json({ ok: false, error: LIC.reason || "invalid", ...license.publicView(LIC), }); } // Offline check passed. Confirm with the licensing server so a key // that was revoked before activation gets rejected immediately. Cap // the wait so a slow server doesn't hang the activation UI — if we // time out, accept the offline-verified state and let the periodic // poll catch up. await Promise.race([ refreshLicenseOnline("activation"), new Promise((resolve) => setTimeout(resolve, ACTIVATE_VALIDATE_TIMEOUT_MS)), ]); if (LIC.state === "licensed") { return res.json({ ok: true, ...license.publicView(LIC) }); } return res.status(400).json({ ok: false, error: LIC.reason || "invalid", ...license.publicView(LIC), }); }); app.post("/api/license/deactivate", (_req, res) => { LIC = license.deactivate(); res.json({ ok: true, ...license.publicView(LIC) }); }); }