29282f8dcc
Two related changes:
1. New StartOS action: 'Set Recap License'
Symmetric with the existing 'Set Gemini API Key' action — paste a
LIC1-... key into the StartOS Actions menu and it gets persisted.
Added because some users prefer the StartOS form for credentials
over the in-app activation modal.
Implementation:
• startos/file-models/config.json.ts: schema gains recap_license_key
• startos/actions/setLicense.ts: input form (masked, regex-checks
for the LIC1- prefix), persists via configFile.merge()
• startos/actions/index.ts: registers the new action
• server/license.js: readLicenseString() falls back to
startos-config.json after the legacy license.txt path. Resolution
order: env → license.txt → startos-config.json
• server/license-middleware.js: faster license-file poll (30 s,
env-overridable RECAP_LICENSE_FILE_POLL_MS) re-runs checkLicense
so action-set keys take effect within seconds, not the 6 h online
cycle. If the new key parses as 'licensed', kicks an immediate
online check to confirm.
2. Copy fix: 'Keysat license' → 'Recap license' in user-facing text
Keysat is the licensing system underneath, but customers buy a
'Recap license'. Updated:
• Activation screen subtitle (public/index.html)
• 402 message in the activation gate (server/license-middleware.js)
Internal references (PRODUCT_SLUG, KEYSAT_BASE_URL, the issuer.pub
filename, the 'Issuer: licensing.keysat.xyz' display in the
activation card) stay as Keysat — those are accurate.
Smoke tested locally: starting the server with no license, then
writing a fake LIC1-... key into startos-config.json, the
license-file poll picks it up within ~2 s and transitions state from
'unlicensed' to 'invalid' (since the fake key fails Ed25519
verification, as expected). With a real key, the same path would land
in 'licensed'.
251 lines
10 KiB
JavaScript
251 lines
10 KiB
JavaScript
// 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) });
|
|
});
|
|
}
|