Module split: license gate + Pro gates + license routes → server/license-middleware.js

• LIC                              — exported live binding (ESM)
  • setupLicenseMiddleware(app)      — registers activation gate + Pro
                                       feature gates (must run before any
                                       /api/* route)
  • setupLicenseRoutes(app)          — /api/license-status, /api/license/
                                       activate, /api/license/deactivate
  • startLicenseRefresh()            — startup + 6h periodic online check
  • refreshLicenseOnline(reason)     — ad-hoc refresh (e.g., during activate)
  • isFreeUser()                     — 'no license || no core entitlement'
  • tryAcquireFreeSlot() / releaseFreeSlot() — the free-tier concurrency
                                                 lock previously open-coded
                                                 in /api/process

Local 'const isFreeUser = ...' in /api/process renamed to 'isFree' to
avoid shadowing the imported helper. Open-coded freeJobInFlight reads/
writes replaced with the slot helpers.

server/index.js: 2461 → 2300 lines.

Smoke tested: server boots; /api/license-status, /api/health, /api/
process (rejects with 400 'No API key' as expected for unlicensed +
no key) all behave as before.
This commit is contained in:
Keysat
2026-05-08 17:05:35 -05:00
parent 7ab2a3249a
commit 5540b71446
2 changed files with 249 additions and 188 deletions
+27 -188
View File
@@ -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 });