Files
recap-relay/server/keysat-client.js
T
2026-05-11 20:03:27 -05:00

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(),
}));
}