184 lines
5.7 KiB
JavaScript
184 lines
5.7 KiB
JavaScript
// 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<licenseKey, { result, validated_at }>
|
|
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 <key>` 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(),
|
|
}));
|
|
}
|