diff --git a/server/index.js b/server/index.js index dd8e598..0ff1af8 100644 --- a/server/index.js +++ b/server/index.js @@ -36,6 +36,16 @@ import { } from "./cookies.js"; import * as config from "./config.js"; import { resolveApiKey } from "./config.js"; +import * as licenseMW from "./license-middleware.js"; +import { + setupLicenseMiddleware, + setupLicenseRoutes, + startLicenseRefresh, + refreshLicenseOnline, + isFreeUser, + tryAcquireFreeSlot, + releaseFreeSlot, +} from "./license-middleware.js"; const execFileAsync = promisify(execFile); const app = express(); @@ -63,184 +73,14 @@ await initCookies({ dataDir: DATA_DIR, envPath }); app.use(cors()); app.use(express.json({ limit: "100mb" })); -// ── Keysat licensing (hard-gate / activate-screen flavor) ───────────────── -// All /api/* routes return 402 until a valid license is activated, except -// the allowlisted endpoints that exist precisely so the frontend can render -// an activation UI. See server/license.js for the verifier. -let LIC = license.checkLicense(); -console.log( - `[license] state=${LIC.state} entitlements=[${[...LIC.entitlements].join(",")}]` + - (LIC.reason ? ` reason=${LIC.reason}` : "") -); - -// Periodic online validation against licensing.keysat.xyz. Catches -// revocations, suspensions, and expirations that happen after activation — -// without it, a revoked key keeps working until the customer reinstalls. -// -// On network errors we keep the prior state up to MAX_OFFLINE_DAYS (see -// server/license.js); past the ceiling we lock out. -const VALIDATE_INTERVAL_MS = parseInt( - process.env.RECAP_VALIDATE_INTERVAL_MS || String(6 * 60 * 60 * 1000), - 10 -); -const ACTIVATE_VALIDATE_TIMEOUT_MS = 8000; - -async function refreshLicenseOnline(reason) { - const prev = LIC; - try { - LIC = await license.validateOnline(); - } catch (e) { - // validateOnline shouldn't throw, but be defensive. - 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(",")}]` - ); - } -} - -// Async startup check — don't block the server from coming up. -refreshLicenseOnline("startup").catch(() => {}); -setInterval(() => { - refreshLicenseOnline("scheduled").catch(() => {}); -}, VALIDATE_INTERVAL_MS); - -// Endpoints reachable without a license — kept intentionally minimal. -// /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 + a one-at-a-time 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", -]); - -// 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 Keysat 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, - }); -}); - -// Free-tier concurrency lock. Unlicensed users may process one video at -// a time — second submission while another is in flight returns 409. -// Released in a finally block at the bottom of /api/process. -let freeJobInFlight = false; - -// 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.", - }, -]; -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 — kept open by LICENSE_OPEN_PATHS above. -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) }); -}); +// ── Keysat licensing ──────────────────────────────────────────────────────── +// All license-aware request handling (gate, Pro feature gates, /api/license +// routes, free-tier slot management, periodic online refresh) lives in +// ./license-middleware.js. Importers read the current state via +// licenseMW.LIC (a live binding). +setupLicenseMiddleware(app); +setupLicenseRoutes(app); +startLicenseRefresh(); // ── History storage ─────────────────────────────────────────────────────── @@ -1192,7 +1032,7 @@ async function checkSubscriptions() { async function _checkSubscriptionsInner() { // Pro-tier feature: skip silently when not entitled. The HTTP gate above // returns 402 to callers; this guards the background timer + manual paths. - if (!LIC.entitlements.has("subscriptions")) { + if (!licenseMW.LIC.entitlements.has("subscriptions")) { subCheckLog = []; subLog("Skipped: subscriptions require a Pro license."); return; @@ -1731,26 +1571,25 @@ app.post("/api/process", async (req, res) => { // web UI Settings panel (client-side). The future "bundled key" relay // (paid users' requests proxied through the operator's service) isn't // built yet, so there's nothing here that gates key sourcing by tier. - const isFreeUser = !(LIC.state === "licensed" && LIC.entitlements.has("core")); - if (isFreeUser) { - if (freeJobInFlight) { + const isFree = isFreeUser(); + if (isFree) { + if (!tryAcquireFreeSlot()) { return res.status(409).json({ error: "processing_in_progress", message: "A summary is already being processed. Free mode handles one video at a time — wait for the current one to finish.", }); } - freeJobInFlight = true; } const apiKey = resolveApiKey(clientKey); if (!url) { - if (isFreeUser) freeJobInFlight = false; + if (isFree) releaseFreeSlot(); return res.status(400).json({ error: "Missing url" }); } if (!apiKey) { - if (isFreeUser) freeJobInFlight = false; + if (isFree) releaseFreeSlot(); return res.status(400).json({ error: "No API key provided. Set GEMINI_API_KEY in .env or enter one in Settings." }); } @@ -1759,7 +1598,7 @@ app.post("/api/process", async (req, res) => { const videoId = isPodcast ? (episodeId || url) : extractVideoId(url); if (!isPodcast && !videoId) { - if (isFreeUser) freeJobInFlight = false; + if (isFree) releaseFreeSlot(); return res.status(400).json({ error: "Invalid YouTube URL" }); } @@ -2354,7 +2193,7 @@ Return ONLY the timestamped transcript, nothing else.`; // the host's library with anonymous summaries. The result still streams // back so the UI can render it; it just isn't persisted. const contentType = isPodcast ? "podcast" : "youtube"; - const historyId = isFreeUser + const historyId = isFree ? null : await saveToHistory(videoId, url, videoTitle, chunks, entries, logHistory, videoUploadDate, contentType).catch(() => null); @@ -2370,7 +2209,7 @@ Return ONLY the timestamped transcript, nothing else.`; res.end(); } } finally { - if (isFreeUser) freeJobInFlight = false; + if (isFree) releaseFreeSlot(); // Clean up temp files try { await fs.rm(tmpDir, { recursive: true, force: true }); diff --git a/server/license-middleware.js b/server/license-middleware.js new file mode 100644 index 0000000..c2420dd --- /dev/null +++ b/server/license-middleware.js @@ -0,0 +1,222 @@ +// 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; + +// ── 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); +} + +// ── 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 Keysat 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) }); + }); +}