// Cached license validation against the Keysat license server. Two // layers: // 1. Offline signature verification using the vendored // @keysat/licensing-client. Fast and works without network. // 2. Cached online check against keysat.xyz (or wherever // relay_keysat_base_url points) — confirms the key hasn't been // revoked since the last sync. Cached per license-key for // KEYSAT_CACHE_TTL_MS to avoid hammering keysat on hot paths. // // Returns a normalized object with tier resolved from entitlements: // { state: "licensed" | "invalid" | "anonymous", // tier: "core" | "pro" | "max", // licenseUuid: string | null, // entitlements: string[], // reason: string | null } import { getConfigSnapshot } from "./config.js"; const KEYSAT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour // Map const cache = new Map(); // Dynamically import the licensing client so it doesn't block boot // if vendor/keysat-licensing-client is missing in dev environments. let verifierLoaded = false; let verifier = null; async function loadVerifier() { if (verifierLoaded) return verifier; verifierLoaded = true; try { const mod = await import("@keysat/licensing-client"); // Same pattern Recap uses: build a verifier with the embedded // public key. Recap's license.js shows the exact call; copy it // here. For v0.1 we only need offline verification — if the // vendor module signature differs across Recap versions we can // tweak this. if (mod?.createVerifier) { verifier = mod.createVerifier(); } } catch (err) { console.warn( `[keysat] failed to load @keysat/licensing-client (${err.message}) — will treat all licenses as anonymous` ); } return verifier; } // Resolve a tier from an entitlements set. Bundles aren't supported // yet — explicit entitlement names only. relay_max wins over relay_pro // if somehow both are present. function tierFromEntitlements(entitlements) { if (entitlements.has("relay_max")) return "max"; if (entitlements.has("relay_pro")) return "pro"; return "core"; } // Public entry: takes the raw `Authorization: Bearer ` value (or // null) and returns a resolved license. Anonymous = no header = Core // tier. export async function resolveLicense(rawAuth) { if (!rawAuth) { return { state: "anonymous", tier: "core", licenseUuid: null, entitlements: [], reason: null, }; } const key = stripBearer(rawAuth); if (!key) { return { state: "invalid", tier: "core", licenseUuid: null, entitlements: [], reason: "malformed_auth_header", }; } // Cache hit (still fresh)? const cached = cache.get(key); if (cached && Date.now() - cached.validated_at < KEYSAT_CACHE_TTL_MS) { return cached.result; } // Offline verify first — establishes the entitlements + license id. const v = await loadVerifier(); let offline = null; if (v) { try { offline = v.verify(key); } catch (err) { const result = { state: "invalid", tier: "core", licenseUuid: null, entitlements: [], reason: `verify_failed: ${err.message}`, }; cache.set(key, { result, validated_at: Date.now() }); return result; } } // If offline verify worked, use its payload as the source of truth // for entitlements + license id. Then hit keysat for revocation // status. const entitlements = new Set(offline?.payload?.entitlements || []); const licenseUuid = offline?.payload?.licenseUuid || null; let online = await onlineCheck(key); if (online && online.revoked) { const result = { state: "invalid", tier: "core", licenseUuid, entitlements: [], reason: online.reason || "revoked", }; cache.set(key, { result, validated_at: Date.now() }); return result; } const tier = tierFromEntitlements(entitlements); const result = { state: "licensed", tier, licenseUuid, entitlements: [...entitlements], reason: null, }; cache.set(key, { result, validated_at: Date.now() }); return result; } function stripBearer(raw) { const m = (raw || "").trim().match(/^Bearer\s+(.+)$/i); if (m) return m[1].trim(); // Accept a bare key without the Bearer prefix for tolerance. return raw.trim(); } // Best-effort online check. Returns null on network error (cache the // offline-verified result with a short TTL so we don't pound keysat // while it's down) or { revoked: boolean, reason?: string }. async function onlineCheck(licenseKey) { try { const cfg = await getConfigSnapshot(); const base = (cfg.relay_keysat_base_url || "").replace(/\/$/, ""); if (!base) return null; // POST /validate is the standard Keysat shape (per the licensing // client docs). If the actual endpoint differs we'll wire it up // once we point the relay at a real Keysat server. const res = await fetch(`${base}/validate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ license_key: licenseKey }), signal: AbortSignal.timeout(5000), }); if (!res.ok) { console.warn(`[keysat] online check ${base}/validate returned ${res.status}`); return null; } const data = await res.json(); return { revoked: !!data?.revoked || data?.status === "revoked", reason: data?.reason || null, }; } catch (err) { console.warn(`[keysat] online check failed: ${err?.message}`); return null; } } export function snapshotCache() { return Array.from(cache.entries()).map(([k, v]) => ({ license_prefix: k.slice(0, 12) + "…", tier: v.result?.tier, state: v.result?.state, validated_at: new Date(v.validated_at).toISOString(), })); }