// ── Keysat license verification ────────────────────────────────────────── // // Reads a LIC1-... key from disk (or env), verifies its Ed25519 signature // against the operator's embedded public key, and exposes the resulting // state + entitlement set to the rest of the server. // // Operator config — keep these three constants in sync with what's set in // the Keysat admin UI: // ISSUER_PEM → assets/issuer.pub (committed; non-secret) // PRODUCT_SLUG → must match the product slug created in Keysat // KEYSAT_BASE_URL → optional, only used by online validate() / purchase // // Tier model for this app (see KEYSAT_INTEGRATION.md §0): // "core" — required for any business endpoint; unlocks // summarization and BYO Gemini API key // "history" — saved summary library: /api/history* // "library" — bulk import/export: /api/library/* // "subscriptions" — Pro: channel subs, auto-queue, sub-check log // "clips" — Pro: paperclip / clip-collection panel // // Tier policies: // Core → ["core", "history", "library"] // Pro → ["core", "history", "library", "subscriptions", "clips"] import fs from "fs"; import path from "path"; import { Verifier, PublicKey, Client } from "@keysat/licensing-client"; export const PRODUCT_SLUG = "youtube-summarizer"; export const KEYSAT_BASE_URL = "https://licensing.keysat.xyz"; const __dirname = path.dirname(new URL(import.meta.url).pathname); const PEM_PATH = path.join(__dirname, "..", "assets", "issuer.pub"); const ISSUER_PEM = fs.readFileSync(PEM_PATH, "utf8"); // License file lives next to existing config/ and history/ in DATA_DIR. // On StartOS that's /data; on local Mac dev it's the project root. const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, ".."); export const LICENSE_PATH = process.env.YT_SUMMARIZER_LICENSE_KEY_PATH || 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) ───────────────────────── let verifier = null; let verifierError = null; try { verifier = new Verifier(PublicKey.fromPem(ISSUER_PEM)); } catch (e) { verifierError = e?.message || String(e); 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 ─────────────────────────────────────────────────────────────── function readLicenseString() { const fromEnv = (process.env.YT_SUMMARIZER_LICENSE_KEY || "").trim(); if (fromEnv) return fromEnv; try { const s = fs.readFileSync(LICENSE_PATH, "utf8").trim(); return s || null; } catch { return null; } } 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 = {}) { return { state: "unlicensed", reason: null, licenseId: null, entitlements: new Set(), expiresAt: null, isTrial: false, lastValidatedAt: null, serverStatus: null, graceUntil: null, ...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 ──────────────────────────────────────────────────────────── // // checkLicense() — read + verify; returns a frozen-ish state object. // 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() { if (verifierError) { return emptyState({ state: "invalid", reason: `bad embedded key: ${verifierError}` }); } const raw = readLicenseString(); if (!raw) return emptyState(); let base; try { const ok = verifier.verify(raw); const payload = ok.payload || {}; // Reject keys minted for a different product (same operator, different SKU). if (payload.productSlug && payload.productSlug !== PRODUCT_SLUG) { return emptyState({ state: "invalid", reason: "product_mismatch" }); } base = emptyState({ state: "licensed", // payload.licenseId is a Uint8Array (raw 16-byte UUID); the canonical // string form is licenseUuid. The frontend treats licenseId as a // string (calls .slice on it), so always send the string here. licenseId: payload.licenseUuid || null, entitlements: new Set(payload.entitlements || []), expiresAt: payload.expiresAt ? new Date(payload.expiresAt * 1000) : null, isTrial: !!(payload.flags & 1), }); } catch (e) { 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. // Returns the new license state. Throws on bad input format only; // 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) { const key = (rawKey || "").trim(); if (!key.startsWith("LIC1-")) { const err = new Error("bad_format"); err.code = "bad_format"; throw err; } // Write atomically-ish: write to temp file then rename. const tmp = LICENSE_PATH + ".tmp"; fs.mkdirSync(path.dirname(LICENSE_PATH), { recursive: true }); fs.writeFileSync(tmp, key + "\n", { mode: 0o600 }); fs.renameSync(tmp, LICENSE_PATH); clearPersistedState(); 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. // Never leaks the raw license key (it's a bearer credential). export function publicView(state) { return { state: state.state, reason: state.reason, licenseId: state.licenseId, entitlements: [...state.entitlements].sort(), expiresAt: state.expiresAt ? state.expiresAt.toISOString() : null, isTrial: !!state.isTrial, productSlug: PRODUCT_SLUG, keysatBaseUrl: KEYSAT_BASE_URL, licensePath: LICENSE_PATH, lastValidatedAt: state.lastValidatedAt ? state.lastValidatedAt.toISOString() : null, serverStatus: state.serverStatus || null, graceUntil: state.graceUntil ? state.graceUntil.toISOString() : null, }; } // has(state, entitlement) — convenience wrapper for feature gates. export function has(state, entitlement) { return state && state.entitlements && state.entitlements.has(entitlement); }