Files
recap-relay/server/keysat-client.js
T

210 lines
7.1 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 fs from "fs";
import path from "path";
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.
// Construct the verifier on first use with the issuer public key
// embedded in the image at assets/issuer.pub — same PEM file Recap-app
// ships, so a license that validates in Recap also validates here.
//
// IMPORTANT: this used to call `mod.createVerifier()` (no args), which
// silently no-op'd because the licensing-client exports a `Verifier`
// CLASS, not a `createVerifier` factory. The result was that the
// verifier instance was always null → offline verification was skipped
// → entitlements always came back empty → tierFromEntitlements()
// always returned "core" regardless of what the license actually
// contained. Symptom: a real Pro license activated cleanly in Recap
// (showing the PRO badge) still saw Core credits on the relay. Fixed
// by using the documented Verifier + PublicKey.fromPem(pem) shape.
let verifierLoaded = false;
let verifier = null;
async function loadVerifier() {
if (verifierLoaded) return verifier;
verifierLoaded = true;
try {
const mod = await import("@keysat/licensing-client");
if (!mod?.Verifier || !mod?.PublicKey) {
console.warn(
"[keysat] @keysat/licensing-client missing Verifier/PublicKey exports — leaving verifier null (all licenses will resolve to Core)"
);
return null;
}
// Locate the embedded issuer PEM. The Docker image copies
// assets/ to /app/assets/, so when this module runs from
// /app/server/keysat-client.js, the PEM is at ../assets/issuer.pub.
// Allow KEYSAT_ISSUER_PEM_PATH env override for local dev / tests.
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const pemPath =
process.env.KEYSAT_ISSUER_PEM_PATH ||
path.join(__dirname, "..", "assets", "issuer.pub");
const pem = fs.readFileSync(pemPath, "utf8");
verifier = new mod.Verifier(mod.PublicKey.fromPem(pem));
console.log(
`[keysat] verifier ready (issuer PEM loaded from ${pemPath})`
);
} catch (err) {
console.warn(
`[keysat] failed to initialize verifier (${err.message}) — all licenses will resolve to Core until this is fixed`
);
}
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(),
}));
}