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}` : "")
|
(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.
|
// Endpoints reachable without a license — kept intentionally minimal.
|
||||||
const LICENSE_OPEN_PATHS = new Set([
|
const LICENSE_OPEN_PATHS = new Set([
|
||||||
"/api/health",
|
"/api/health",
|
||||||
@@ -186,7 +222,7 @@ app.get("/api/license-status", (_req, res) => {
|
|||||||
res.json(license.publicView(LIC));
|
res.json(license.publicView(LIC));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/license/activate", (req, res) => {
|
app.post("/api/license/activate", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
LIC = license.activate(req.body && req.body.license_key);
|
LIC = license.activate(req.body && req.body.license_key);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -198,21 +234,36 @@ app.post("/api/license/activate", (req, res) => {
|
|||||||
}
|
}
|
||||||
return res.status(500).json({ error: "activation_failed", message: e?.message });
|
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") {
|
if (LIC.state === "licensed") {
|
||||||
return res.json({ ok: true, ...license.publicView(LIC) });
|
return res.json({ ok: true, ...license.publicView(LIC) });
|
||||||
}
|
}
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "invalid",
|
error: LIC.reason || "invalid",
|
||||||
...license.publicView(LIC),
|
...license.publicView(LIC),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/license/deactivate", async (_req, res) => {
|
app.post("/api/license/deactivate", (_req, res) => {
|
||||||
try {
|
LIC = license.deactivate();
|
||||||
await fs.unlink(license.LICENSE_PATH).catch(() => {});
|
|
||||||
} catch {}
|
|
||||||
LIC = license.checkLicense();
|
|
||||||
res.json({ ok: true, ...license.publicView(LIC) });
|
res.json({ ok: true, ...license.publicView(LIC) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+232
-5
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { Verifier, PublicKey } from "@keysat/licensing-client";
|
import { Verifier, PublicKey, Client } from "@keysat/licensing-client";
|
||||||
|
|
||||||
export const PRODUCT_SLUG = "youtube-summarizer";
|
export const PRODUCT_SLUG = "youtube-summarizer";
|
||||||
export const KEYSAT_BASE_URL = "https://licensing.keysat.xyz";
|
export const KEYSAT_BASE_URL = "https://licensing.keysat.xyz";
|
||||||
@@ -40,6 +40,21 @@ export const LICENSE_PATH =
|
|||||||
process.env.YT_SUMMARIZER_LICENSE_KEY_PATH ||
|
process.env.YT_SUMMARIZER_LICENSE_KEY_PATH ||
|
||||||
path.join(DATA_DIR, "license.txt");
|
path.join(DATA_DIR, "license.txt");
|
||||||
|
|
||||||
|
// Grace ceiling for network errors. As long as we successfully validated
|
||||||
|
// against Keysat within this window, we keep the license live even if
|
||||||
|
// subsequent online checks fail (Keysat down, customer offline, etc.).
|
||||||
|
// Past the ceiling, we lock out — otherwise a revoked key on a permanently
|
||||||
|
// offline machine would never get caught.
|
||||||
|
const MAX_OFFLINE_DAYS = parseInt(
|
||||||
|
process.env.YT_SUMMARIZER_MAX_OFFLINE_DAYS || "7",
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const MAX_OFFLINE_MS = MAX_OFFLINE_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Sidecar file that tracks last successful online validation. Lets the
|
||||||
|
// grace window survive restarts.
|
||||||
|
const STATE_PATH = LICENSE_PATH + ".state.json";
|
||||||
|
|
||||||
// ── Verifier instance (built once at module load) ─────────────────────────
|
// ── Verifier instance (built once at module load) ─────────────────────────
|
||||||
let verifier = null;
|
let verifier = null;
|
||||||
let verifierError = null;
|
let verifierError = null;
|
||||||
@@ -50,6 +65,13 @@ try {
|
|||||||
console.error(`[license] failed to parse embedded public key: ${verifierError}`);
|
console.error(`[license] failed to parse embedded public key: ${verifierError}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lazy HTTP client for online validation against the licensing service.
|
||||||
|
let onlineClient = null;
|
||||||
|
function getOnlineClient() {
|
||||||
|
if (!onlineClient) onlineClient = new Client(KEYSAT_BASE_URL);
|
||||||
|
return onlineClient;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
function readLicenseString() {
|
function readLicenseString() {
|
||||||
const fromEnv = (process.env.YT_SUMMARIZER_LICENSE_KEY || "").trim();
|
const fromEnv = (process.env.YT_SUMMARIZER_LICENSE_KEY || "").trim();
|
||||||
@@ -62,6 +84,30 @@ function readLicenseString() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPersistedState() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePersistedState(obj) {
|
||||||
|
try {
|
||||||
|
const tmp = STATE_PATH + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, STATE_PATH);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[license] failed to write state: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPersistedState() {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(STATE_PATH);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function emptyState(extra = {}) {
|
function emptyState(extra = {}) {
|
||||||
return {
|
return {
|
||||||
state: "unlicensed",
|
state: "unlicensed",
|
||||||
@@ -70,14 +116,35 @@ function emptyState(extra = {}) {
|
|||||||
entitlements: new Set(),
|
entitlements: new Set(),
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
isTrial: false,
|
isTrial: false,
|
||||||
|
lastValidatedAt: null,
|
||||||
|
serverStatus: null,
|
||||||
|
graceUntil: null,
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Definitive server reasons that should immediately invalidate the license.
|
||||||
|
// rate_limited is intentionally NOT here — that's a transient server-side
|
||||||
|
// throttle, not a verdict on the key.
|
||||||
|
const HARD_REJECTIONS = new Set([
|
||||||
|
"bad_format",
|
||||||
|
"bad_signature",
|
||||||
|
"not_found",
|
||||||
|
"revoked",
|
||||||
|
"suspended",
|
||||||
|
"expired",
|
||||||
|
"product_mismatch",
|
||||||
|
"fingerprint_mismatch",
|
||||||
|
"too_many_machines",
|
||||||
|
"invalid_state",
|
||||||
|
]);
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// checkLicense() — read + verify; returns a frozen-ish state object.
|
// checkLicense() — read + verify; returns a frozen-ish state object.
|
||||||
// Callers can re-invoke after activation to refresh.
|
// Callers can re-invoke after activation to refresh. This is offline-only:
|
||||||
|
// it verifies the Ed25519 signature and layers on any persisted online-
|
||||||
|
// validation state. The hard online check happens in validateOnline().
|
||||||
export function checkLicense() {
|
export function checkLicense() {
|
||||||
if (verifierError) {
|
if (verifierError) {
|
||||||
return emptyState({ state: "invalid", reason: `bad embedded key: ${verifierError}` });
|
return emptyState({ state: "invalid", reason: `bad embedded key: ${verifierError}` });
|
||||||
@@ -85,6 +152,7 @@ export function checkLicense() {
|
|||||||
const raw = readLicenseString();
|
const raw = readLicenseString();
|
||||||
if (!raw) return emptyState();
|
if (!raw) return emptyState();
|
||||||
|
|
||||||
|
let base;
|
||||||
try {
|
try {
|
||||||
const ok = verifier.verify(raw);
|
const ok = verifier.verify(raw);
|
||||||
const payload = ok.payload || {};
|
const payload = ok.payload || {};
|
||||||
@@ -92,22 +160,47 @@ export function checkLicense() {
|
|||||||
if (payload.productSlug && payload.productSlug !== PRODUCT_SLUG) {
|
if (payload.productSlug && payload.productSlug !== PRODUCT_SLUG) {
|
||||||
return emptyState({ state: "invalid", reason: "product_mismatch" });
|
return emptyState({ state: "invalid", reason: "product_mismatch" });
|
||||||
}
|
}
|
||||||
return {
|
base = emptyState({
|
||||||
state: "licensed",
|
state: "licensed",
|
||||||
reason: null,
|
|
||||||
licenseId: payload.licenseId || null,
|
licenseId: payload.licenseId || null,
|
||||||
entitlements: new Set(payload.entitlements || []),
|
entitlements: new Set(payload.entitlements || []),
|
||||||
expiresAt: payload.expiresAt ? new Date(payload.expiresAt * 1000) : null,
|
expiresAt: payload.expiresAt ? new Date(payload.expiresAt * 1000) : null,
|
||||||
isTrial: !!(payload.flags & 1),
|
isTrial: !!(payload.flags & 1),
|
||||||
};
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return emptyState({ state: "invalid", reason: e?.message || "verify_failed" });
|
return emptyState({ state: "invalid", reason: e?.message || "verify_failed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Layer on persisted online state. If a previous online check found this
|
||||||
|
// key revoked/suspended/etc., honor that until a successful re-check.
|
||||||
|
const persisted = readPersistedState();
|
||||||
|
if (persisted) {
|
||||||
|
if (persisted.lastValidatedAt) {
|
||||||
|
base.lastValidatedAt = new Date(persisted.lastValidatedAt);
|
||||||
|
}
|
||||||
|
if (persisted.serverStatus) base.serverStatus = persisted.serverStatus;
|
||||||
|
if (persisted.graceUntil) base.graceUntil = new Date(persisted.graceUntil);
|
||||||
|
if (persisted.lastResult && persisted.lastResult !== "ok") {
|
||||||
|
// The last conclusive online check rejected this key. Stay invalid
|
||||||
|
// until a successful re-check overwrites the sidecar.
|
||||||
|
return emptyState({
|
||||||
|
state: "invalid",
|
||||||
|
reason: persisted.lastResult,
|
||||||
|
licenseId: base.licenseId,
|
||||||
|
lastValidatedAt: base.lastValidatedAt,
|
||||||
|
serverStatus: base.serverStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
// activate(rawKey) — write a pasted key to disk, then re-check.
|
// activate(rawKey) — write a pasted key to disk, then re-check.
|
||||||
// Returns the new license state. Throws on bad input format only;
|
// Returns the new license state. Throws on bad input format only;
|
||||||
// signature failures surface as state: 'invalid' with a reason.
|
// signature failures surface as state: 'invalid' with a reason.
|
||||||
|
//
|
||||||
|
// Clears any persisted online-validation state — a new key gets a fresh
|
||||||
|
// online check, untainted by what an old key was last told.
|
||||||
export function activate(rawKey) {
|
export function activate(rawKey) {
|
||||||
const key = (rawKey || "").trim();
|
const key = (rawKey || "").trim();
|
||||||
if (!key.startsWith("LIC1-")) {
|
if (!key.startsWith("LIC1-")) {
|
||||||
@@ -120,9 +213,138 @@ export function activate(rawKey) {
|
|||||||
fs.mkdirSync(path.dirname(LICENSE_PATH), { recursive: true });
|
fs.mkdirSync(path.dirname(LICENSE_PATH), { recursive: true });
|
||||||
fs.writeFileSync(tmp, key + "\n", { mode: 0o600 });
|
fs.writeFileSync(tmp, key + "\n", { mode: 0o600 });
|
||||||
fs.renameSync(tmp, LICENSE_PATH);
|
fs.renameSync(tmp, LICENSE_PATH);
|
||||||
|
clearPersistedState();
|
||||||
return checkLicense();
|
return checkLicense();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deactivate() — remove the on-disk license + persisted online state.
|
||||||
|
// Idempotent. Returns the new (empty) state.
|
||||||
|
export function deactivate() {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(LICENSE_PATH);
|
||||||
|
} catch {}
|
||||||
|
clearPersistedState();
|
||||||
|
return checkLicense();
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateOnline() — call licensing.keysat.xyz/v1/validate, merge the
|
||||||
|
// response into a fresh state object, and persist last-validated info.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// • Server says ok=true → state stays/becomes "licensed", entitlements
|
||||||
|
// and expiry refreshed from server.
|
||||||
|
// • Server says ok=false with a hard reason (revoked, suspended, expired,
|
||||||
|
// not_found, product_mismatch, fingerprint_mismatch, too_many_machines)
|
||||||
|
// → state becomes "invalid"; persisted so
|
||||||
|
// checkLicense() keeps rejecting after restart.
|
||||||
|
// • Server says rate_limited → treated as a transient error; state kept.
|
||||||
|
// • Network error / timeout → state kept up to MAX_OFFLINE_DAYS since the
|
||||||
|
// last successful validate. Past the ceiling,
|
||||||
|
// state becomes "invalid" with reason
|
||||||
|
// "validation_overdue".
|
||||||
|
//
|
||||||
|
// Always returns a state object — never throws.
|
||||||
|
export async function validateOnline() {
|
||||||
|
const local = checkLicense();
|
||||||
|
// No key, or local sig failure — no point asking the server.
|
||||||
|
if (local.state !== "licensed" && local.state !== "invalid") return local;
|
||||||
|
const raw = readLicenseString();
|
||||||
|
if (!raw) return local;
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await getOnlineClient().validate(raw, { productSlug: PRODUCT_SLUG });
|
||||||
|
} catch (e) {
|
||||||
|
return applyNetworkErrorGrace(local, e?.code || e?.message || "network_error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const next = emptyState({
|
||||||
|
state: "licensed",
|
||||||
|
licenseId: resp.licenseId || local.licenseId,
|
||||||
|
entitlements: new Set(resp.entitlements || [...local.entitlements]),
|
||||||
|
expiresAt: resp.expiresAt ? new Date(resp.expiresAt) : local.expiresAt,
|
||||||
|
isTrial: resp.isTrial != null ? !!resp.isTrial : local.isTrial,
|
||||||
|
lastValidatedAt: now,
|
||||||
|
serverStatus: resp.status || "active",
|
||||||
|
graceUntil: resp.graceUntil ? new Date(resp.graceUntil) : null,
|
||||||
|
});
|
||||||
|
writePersistedState({
|
||||||
|
lastValidatedAt: now.toISOString(),
|
||||||
|
serverStatus: next.serverStatus,
|
||||||
|
lastResult: "ok",
|
||||||
|
graceUntil: next.graceUntil ? next.graceUntil.toISOString() : null,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = resp.reason || "rejected";
|
||||||
|
|
||||||
|
if (reason === "rate_limited") {
|
||||||
|
// Transient. Don't change state.
|
||||||
|
return applyNetworkErrorGrace(local, "rate_limited");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HARD_REJECTIONS.has(reason)) {
|
||||||
|
console.warn(`[license] online validation rejected: ${reason}`);
|
||||||
|
writePersistedState({
|
||||||
|
lastValidatedAt: now.toISOString(),
|
||||||
|
serverStatus: resp.status || reason,
|
||||||
|
lastResult: reason,
|
||||||
|
});
|
||||||
|
return emptyState({
|
||||||
|
state: "invalid",
|
||||||
|
reason,
|
||||||
|
licenseId: local.licenseId,
|
||||||
|
lastValidatedAt: now,
|
||||||
|
serverStatus: resp.status || reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown reason — be conservative, treat as transient so we don't lock
|
||||||
|
// out paying users on an SDK/server version mismatch.
|
||||||
|
console.warn(`[license] online validation returned unknown reason: ${reason}`);
|
||||||
|
return applyNetworkErrorGrace(local, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNetworkErrorGrace(local, errorReason) {
|
||||||
|
const persisted = readPersistedState();
|
||||||
|
const lastValidatedAt = persisted?.lastValidatedAt
|
||||||
|
? new Date(persisted.lastValidatedAt)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!lastValidatedAt) {
|
||||||
|
// Never successfully validated online. Allow offline state but flag it
|
||||||
|
// — the periodic poller will keep trying.
|
||||||
|
console.warn(
|
||||||
|
`[license] online validation unavailable (${errorReason}); no prior successful check yet`
|
||||||
|
);
|
||||||
|
return { ...local, lastValidatedAt: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageMs = Date.now() - lastValidatedAt.getTime();
|
||||||
|
if (ageMs > MAX_OFFLINE_MS) {
|
||||||
|
const ageDays = (ageMs / 86400000).toFixed(1);
|
||||||
|
console.warn(
|
||||||
|
`[license] online validation overdue: ${ageDays}d since last successful check (max ${MAX_OFFLINE_DAYS}d). Locking out.`
|
||||||
|
);
|
||||||
|
return emptyState({
|
||||||
|
state: "invalid",
|
||||||
|
reason: "validation_overdue",
|
||||||
|
licenseId: local.licenseId,
|
||||||
|
lastValidatedAt,
|
||||||
|
serverStatus: persisted?.serverStatus || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[license] online validation skipped (${errorReason}); within ${MAX_OFFLINE_DAYS}d grace`
|
||||||
|
);
|
||||||
|
return { ...local, lastValidatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
// publicView(state) — safe shape for /api/license-status responses.
|
// publicView(state) — safe shape for /api/license-status responses.
|
||||||
// Never leaks the raw license key (it's a bearer credential).
|
// Never leaks the raw license key (it's a bearer credential).
|
||||||
export function publicView(state) {
|
export function publicView(state) {
|
||||||
@@ -136,6 +358,11 @@ export function publicView(state) {
|
|||||||
productSlug: PRODUCT_SLUG,
|
productSlug: PRODUCT_SLUG,
|
||||||
keysatBaseUrl: KEYSAT_BASE_URL,
|
keysatBaseUrl: KEYSAT_BASE_URL,
|
||||||
licensePath: LICENSE_PATH,
|
licensePath: LICENSE_PATH,
|
||||||
|
lastValidatedAt: state.lastValidatedAt
|
||||||
|
? state.lastValidatedAt.toISOString()
|
||||||
|
: null,
|
||||||
|
serverStatus: state.serverStatus || null,
|
||||||
|
graceUntil: state.graceUntil ? state.graceUntil.toISOString() : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user