// 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 with details about what's running. The /api/process // handler calls tryAcquireFreeSlot() at entry and releaseFreeSlot() in // its finally block. // // The current-job object also drives: // - /api/process/current — UI status banner after a browser refresh // - /api/process/cancel — sets `aborted: true` AND fires the request's // AbortController so in-flight model API calls are interrupted // immediately (not just at the next pipeline checkpoint). let currentFreeJob = null; // { url, title, startedAt, aborted, abortController } | null // ── Online validation tunables ────────────────────────────────────────────── // 30 min default scheduled cycle catches revocations / suspensions / // expirations within at most half an hour. Bounded so a key revoked on // Keysat doesn't sit unnoticed for hours on the customer's machine. const VALIDATE_INTERVAL_MS = parseInt( process.env.RECAP_VALIDATE_INTERVAL_MS || String(30 * 60 * 1000), 10 ); const ACTIVATE_VALIDATE_TIMEOUT_MS = 8000; // 5 s file poll is the fast path for keys set via the StartOS "Set Recap // License" action — the cost is one stat call per file every 5 s, which // is negligible. const LICENSE_FILE_POLL_MS = parseInt( process.env.RECAP_LICENSE_FILE_POLL_MS || "5000", 10 ); // Opportunistic refresh: when /api/license-status is hit and the cached // LIC was last validated more than this long ago, fire validateOnline in // the background. The web UI hits license-status on every page load, so // revocations get caught the next time anyone opens the app — usually // well under the scheduled 30 min tick. const OPPORTUNISTIC_REFRESH_THRESHOLD_MS = parseInt( process.env.RECAP_OPPORTUNISTIC_REFRESH_MS || String(10 * 60 * 1000), 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 paid user — i.e. holds either the // `pro` or `max` entitlement. Keysat policy cards mint Pro licenses with // `pro` and Max licenses with `max`; both unlock the same Recap-side // gates today (subscriptions, no free-tier concurrency lock), with the // relay layer responsible for the Pro-vs-Max quota split. export function isPaidUser() { if (LIC.state !== "licensed") return false; return LIC.entitlements.has("pro") || LIC.entitlements.has("max"); } // Inverse of isPaidUser — kept as a separate export because that's how // most callers phrase the check ("if free, apply rate limits / show // upgrade banner / etc."). export function isFreeUser() { return !isPaidUser(); } // 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. // // `abortController` is the request's AbortController — abortCurrentFreeJob // calls .abort() on it so in-flight provider SDK calls are interrupted at // the network layer, not just at the next pipeline checkpoint. // // `logs` is a server-side buffer the pipeline appends to (via // appendCurrentJobLog) as each progress message is sent over SSE. After // a browser refresh the client re-fetches /api/process/current and uses // these to repopulate the activity log — without it, a refresh during // a long pipeline silently drops everything the user has already seen. export function tryAcquireFreeSlot({ url = "", title = "", abortController = null } = {}) { if (currentFreeJob) return false; currentFreeJob = { url, title, startedAt: Date.now(), aborted: false, abortController, logs: [], }; return true; } // Push one entry onto the in-flight job's log buffer. No-op if there's // no current job (e.g. licensed user — no free-tier tracking). Kept // bounded so a multi-hour run doesn't grow the buffer without limit. const MAX_LIVE_LOG_ENTRIES = 500; export function appendCurrentJobLog(entry) { if (!currentFreeJob || !entry) return; currentFreeJob.logs.push(entry); if (currentFreeJob.logs.length > MAX_LIVE_LOG_ENTRIES) { currentFreeJob.logs.splice(0, currentFreeJob.logs.length - MAX_LIVE_LOG_ENTRIES); } } export function releaseFreeSlot() { currentFreeJob = null; } // Returns a JSON-friendly snapshot of the in-flight free job, or null. // `includeLogs` is opt-in because the typical poll (banner refresh) only // cares about the small header fields — logs are only needed when the // client is rehydrating after a browser refresh. export function getCurrentFreeJob({ includeLogs = false } = {}) { if (!currentFreeJob) return null; const out = { url: currentFreeJob.url, title: currentFreeJob.title, startedAt: currentFreeJob.startedAt, elapsedMs: Date.now() - currentFreeJob.startedAt, aborted: currentFreeJob.aborted, }; if (includeLogs) out.logs = [...currentFreeJob.logs]; return out; } // Mark the current job as cancelled AND fire its AbortController so any // in-flight provider SDK call rejects immediately. Pipeline code also // polls isFreeJobAborted() at major checkpoints — that handles the gaps // between awaitable calls (e.g. while looping over yt-dlp retry delays). // The handler's finally block runs releaseFreeSlot(), so we don't clear // currentFreeJob here — that avoids a race where a follow-up /api/process // request acquires the slot while the cancelled call is still cleaning up. // Returns true if there was a job to cancel. export function abortCurrentFreeJob() { if (!currentFreeJob) return false; currentFreeJob.aborted = true; try { currentFreeJob.abortController?.abort(); } catch {} return true; } export function isFreeJobAborted() { return !!(currentFreeJob && currentFreeJob.aborted); } // ── 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", // Install identity — needed by the relay client before any license // exists, and by the UI's settings panel for verification. "/api/install-id", // Relay balance display — the UI needs to render credit counts even // for unlicensed (Core) users since they get free lifetime credits. "/api/relay/status", // Tier-policy lookup powers dynamic copy on the activation screen // (e.g. "N relay credits" pulled live from the relay). Unlicensed // users see the activation screen, so this must be open to them. "/api/relay/policy", ]); // Prefix-based open list: any /api/* path that startsWith one of these // is reachable without a license. Library + saved summaries are part of // the free experience (the app would feel broken without them — you'd // summarize a video and never be able to find it again). Subscriptions, // clips, and the relay remain paid. /api/providers/* is open so any // user (including unlicensed) can test connectivity to their LLM // providers before deciding whether to buy. /api/process is a prefix // (not an exact-match in LICENSE_OPEN_PATHS) because /api/process, // /api/process/current, and /api/process/cancel all need to be reachable // for the free-tier flow — without /current the in-flight banner can't // clear after the pipeline finishes, and without /cancel the Cancel // button silently fails for unlicensed users. const LICENSE_OPEN_PREFIXES = [ "/api/history", "/api/library", "/api/providers", "/api/process", // Audio-first ("walking mode") TTS. The /api/tts routes self-gate // access (Max entitlement in multi mode; operator-only otherwise), so // the blanket license middleware must let them through to that gate // rather than 402-ing single-mode operators or Max users here. "/api/tts", // In-app purchase flow: GET /api/license/policies, POST // /api/license/purchase, GET /api/license/poll/. Buyers // are unlicensed by definition — they must reach these before any // license exists. "/api/license/policies", "/api/license/purchase", "/api/license/poll", // Relay credit top-up purchases: GET /api/credits/packages, POST // /api/credits/buy, GET /api/credits/invoice/. Buying credits // doesn't require a license — Core (free) users should be able to // top up just as easily as Pro/Max. The relay itself enforces // billing via BTCPay; we just proxy. "/api/credits", // Self-serve subscription purchase: POST /api/billing/buy, GET // /api/billing/status. A Core (free) user buying their way UP to // Pro/Max is unlicensed by definition, so the activation gate must // let them reach the buy + poll routes. The routes self-gate to a // real signed-in user (req.user.id). "/api/billing", ]; // ── 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). // // History + library used to be gated here. They moved to the free tier // (see LICENSE_OPEN_PREFIXES above) — without saved summaries the app // feels broken on first use, and the real paid value is auto-queue + // relay credits. 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 or Max plan. 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 (LICENSE_OPEN_PREFIXES.some((p) => req.path.startsWith(p))) return next(); if (isPaidUser()) return next(); return res.status(402).json({ error: "license_required", message: LIC.state === "licensed" ? "Your license is missing the 'pro' or 'max' entitlement. Contact the seller." : "This feature requires a Recaps 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))) { // Multi mode (cloud): per-tenant — the user's relay-owned tier // decides. Pro/Max (or the admin/operator) get in; free tenants get // a clear 402. This is the per-tenant subscriptions gate. if (req.recapMode === "multi") { const tier = req.user?.tier; if ( (req.user && req.user.is_admin) || tier === "pro" || tier === "max" ) { return next(); } return res.status(402).json({ error: "feature_not_in_tier", feature: gate.feature, message: gate.message, }); } // Single mode: the operator's own license carries the entitlement. 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) => { // ── Multi-mode: return per-user view ──────────────────────────────── // The OPERATOR's license at /data/license.txt is "the install" — the // pool that pays for free + trial users. Each signed-in cloud user // has their own state: // - paid user (users.tier = pro|max) → synthesize a license view // from their relay-owned tier (core-decoupling: no Keysat license) // - free tenant (signed in, Core tier) → unlicensed view (they're // not Pro; their balance comes from tenant_credits via /relay/status) // - anonymous/trial → unlicensed view (the badge should show trial // credits, NOT the operator's PRO tier) // - admin (is_admin = 1) → the operator's LIC (they ARE the // operator; same UX as single mode) if (req.recapMode === "multi") { if (req.user && req.user.is_admin) { // Operator viewing their own server: full operator license view. return res.json(license.publicView(LIC)); } // Paid cloud user — the tier is the relay-owned subscription tier, // cached on the Recaps account (req.user.tier) and kept in sync by // the operator grant flow. Synthesize a license view from it so the // badge + per-user gates match a license-bearing user. Core- // decoupling: this is the SOLE source of paid status in multi mode — // a leftover per-user keysat_license is deliberately NOT consulted // (licenses are moot in the cloud path), so the badge always agrees // with the relay-owned tier shown in the operator's Tenants panel. const tier = req.user?.tier; if (req.user && (tier === "pro" || tier === "max")) { return res.json(license.viewForTier(tier)); } // Free tenant (tier core), trial, or fully anonymous — return an // unlicensed view. Frontend uses this to hide the PRO badge / "manage // license" affordances. Balance display comes from /api/relay/status // (which is also multi-mode-aware). return res.json(license.publicView(license.parseLicenseKey(""))); } // ── Single-mode (the existing path) ───────────────────────────────── // Opportunistic refresh: if the cached state is more than // OPPORTUNISTIC_REFRESH_THRESHOLD_MS old, fire a validateOnline in // the background. Doesn't block the response — the next status hit // (or the next browser refresh) sees the updated state. Caps the // worst-case revocation-detection latency for active users at the // threshold value (default 10 min). if (LIC.state === "licensed") { const lastValidated = LIC.lastValidatedAt ? new Date(LIC.lastValidatedAt).getTime() : 0; const ageMs = Date.now() - lastValidated; if (ageMs > OPPORTUNISTIC_REFRESH_THRESHOLD_MS) { refreshLicenseOnline("opportunistic").catch(() => {}); } } 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) }); }); }