Add online license revocation check (Keysat /v1/validate)
Without this, a license revoked in the Keysat admin UI keeps unlocking
the app on the customer's machine — Ed25519 signatures are perpetually
valid, so the offline-only check never sees the revocation.
What this adds:
• license.js: validateOnline() calls licensing.keysat.xyz/v1/validate
via @keysat/licensing-client's Client. Hard rejections (revoked,
suspended, expired, not_found, product_mismatch, fingerprint_mismatch,
too_many_machines, invalid_state) immediately flip state to "invalid"
and persist the verdict to <license>.state.json so it survives
restarts. rate_limited and unknown reasons are treated as transient.
• Network errors keep the prior state for up to MAX_OFFLINE_DAYS
(default 7, env-overridable) since the last successful validate.
Past the ceiling, lock out with reason=validation_overdue. This
avoids breaking customers when Keysat is briefly down while still
catching revocations on machines that go offline forever.
• license.js: deactivate() helper that removes both license.txt and
its sidecar state file (idempotent). publicView() now exposes
lastValidatedAt, serverStatus, graceUntil for the UI.
• index.js: refreshLicenseOnline() runs on startup (async, non-
blocking), every 6h thereafter (env-overridable), and at activation
time with an 8s timeout cap so a slow Keysat doesn't hang the
activation UI. State changes are logged.
• index.js: /api/license/activate now awaits an online confirmation
after the offline signature check passes. A revoked key pasted into
the activation modal fails fast instead of working until the next
poll.
This commit is contained in:
+58
-7
@@ -107,6 +107,42 @@ console.log(
|
||||
(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.YT_SUMMARIZER_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.
|
||||
const LICENSE_OPEN_PATHS = new Set([
|
||||
"/api/health",
|
||||
@@ -186,7 +222,7 @@ app.get("/api/license-status", (_req, res) => {
|
||||
res.json(license.publicView(LIC));
|
||||
});
|
||||
|
||||
app.post("/api/license/activate", (req, res) => {
|
||||
app.post("/api/license/activate", async (req, res) => {
|
||||
try {
|
||||
LIC = license.activate(req.body && req.body.license_key);
|
||||
} catch (e) {
|
||||
@@ -198,21 +234,36 @@ app.post("/api/license/activate", (req, res) => {
|
||||
}
|
||||
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: "invalid",
|
||||
error: LIC.reason || "invalid",
|
||||
...license.publicView(LIC),
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/license/deactivate", async (_req, res) => {
|
||||
try {
|
||||
await fs.unlink(license.LICENSE_PATH).catch(() => {});
|
||||
} catch {}
|
||||
LIC = license.checkLicense();
|
||||
app.post("/api/license/deactivate", (_req, res) => {
|
||||
LIC = license.deactivate();
|
||||
res.json({ ok: true, ...license.publicView(LIC) });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user