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:
+27
-188
@@ -36,6 +36,16 @@ import {
|
|||||||
} from "./cookies.js";
|
} from "./cookies.js";
|
||||||
import * as config from "./config.js";
|
import * as config from "./config.js";
|
||||||
import { resolveApiKey } 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 execFileAsync = promisify(execFile);
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -63,184 +73,14 @@ await initCookies({ dataDir: DATA_DIR, envPath });
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: "100mb" }));
|
app.use(express.json({ limit: "100mb" }));
|
||||||
|
|
||||||
// ── Keysat licensing (hard-gate / activate-screen flavor) ─────────────────
|
// ── Keysat licensing ────────────────────────────────────────────────────────
|
||||||
// All /api/* routes return 402 until a valid license is activated, except
|
// All license-aware request handling (gate, Pro feature gates, /api/license
|
||||||
// the allowlisted endpoints that exist precisely so the frontend can render
|
// routes, free-tier slot management, periodic online refresh) lives in
|
||||||
// an activation UI. See server/license.js for the verifier.
|
// ./license-middleware.js. Importers read the current state via
|
||||||
let LIC = license.checkLicense();
|
// licenseMW.LIC (a live binding).
|
||||||
console.log(
|
setupLicenseMiddleware(app);
|
||||||
`[license] state=${LIC.state} entitlements=[${[...LIC.entitlements].join(",")}]` +
|
setupLicenseRoutes(app);
|
||||||
(LIC.reason ? ` reason=${LIC.reason}` : "")
|
startLicenseRefresh();
|
||||||
);
|
|
||||||
|
|
||||||
// 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) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── History storage ───────────────────────────────────────────────────────
|
// ── History storage ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1192,7 +1032,7 @@ async function checkSubscriptions() {
|
|||||||
async function _checkSubscriptionsInner() {
|
async function _checkSubscriptionsInner() {
|
||||||
// Pro-tier feature: skip silently when not entitled. The HTTP gate above
|
// Pro-tier feature: skip silently when not entitled. The HTTP gate above
|
||||||
// returns 402 to callers; this guards the background timer + manual paths.
|
// returns 402 to callers; this guards the background timer + manual paths.
|
||||||
if (!LIC.entitlements.has("subscriptions")) {
|
if (!licenseMW.LIC.entitlements.has("subscriptions")) {
|
||||||
subCheckLog = [];
|
subCheckLog = [];
|
||||||
subLog("Skipped: subscriptions require a Pro license.");
|
subLog("Skipped: subscriptions require a Pro license.");
|
||||||
return;
|
return;
|
||||||
@@ -1731,26 +1571,25 @@ app.post("/api/process", async (req, res) => {
|
|||||||
// web UI Settings panel (client-side). The future "bundled key" relay
|
// web UI Settings panel (client-side). The future "bundled key" relay
|
||||||
// (paid users' requests proxied through the operator's service) isn't
|
// (paid users' requests proxied through the operator's service) isn't
|
||||||
// built yet, so there's nothing here that gates key sourcing by tier.
|
// built yet, so there's nothing here that gates key sourcing by tier.
|
||||||
const isFreeUser = !(LIC.state === "licensed" && LIC.entitlements.has("core"));
|
const isFree = isFreeUser();
|
||||||
if (isFreeUser) {
|
if (isFree) {
|
||||||
if (freeJobInFlight) {
|
if (!tryAcquireFreeSlot()) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: "processing_in_progress",
|
error: "processing_in_progress",
|
||||||
message:
|
message:
|
||||||
"A summary is already being processed. Free mode handles one video at a time — wait for the current one to finish.",
|
"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);
|
const apiKey = resolveApiKey(clientKey);
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
if (isFreeUser) freeJobInFlight = false;
|
if (isFree) releaseFreeSlot();
|
||||||
return res.status(400).json({ error: "Missing url" });
|
return res.status(400).json({ error: "Missing url" });
|
||||||
}
|
}
|
||||||
if (!apiKey) {
|
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." });
|
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);
|
const videoId = isPodcast ? (episodeId || url) : extractVideoId(url);
|
||||||
|
|
||||||
if (!isPodcast && !videoId) {
|
if (!isPodcast && !videoId) {
|
||||||
if (isFreeUser) freeJobInFlight = false;
|
if (isFree) releaseFreeSlot();
|
||||||
return res.status(400).json({ error: "Invalid YouTube URL" });
|
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
|
// the host's library with anonymous summaries. The result still streams
|
||||||
// back so the UI can render it; it just isn't persisted.
|
// back so the UI can render it; it just isn't persisted.
|
||||||
const contentType = isPodcast ? "podcast" : "youtube";
|
const contentType = isPodcast ? "podcast" : "youtube";
|
||||||
const historyId = isFreeUser
|
const historyId = isFree
|
||||||
? null
|
? null
|
||||||
: await saveToHistory(videoId, url, videoTitle, chunks, entries, logHistory, videoUploadDate, contentType).catch(() => 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();
|
res.end();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isFreeUser) freeJobInFlight = false;
|
if (isFree) releaseFreeSlot();
|
||||||
// Clean up temp files
|
// Clean up temp files
|
||||||
try {
|
try {
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user