// In-memory cache of the most recent relay-reported credit balance + tier, // keyed by credit-key so each distinct (installId, license) pair has its // own snapshot. Multi-mode introduces multiple identities in one Recap // process (operator's pool + each paid user's license), and a single // global snapshot would let one user's relay ping clobber another's // cached view. // // Credit-key shape mirrors the relay's own keying (Path 3 license-keyed // credits): `lic:` for paid licenses, // `inst:` for unlicensed / Core. Stable per identity, doesn't // require parsing the license payload — we hash the raw LIC1 string, // which works as a local cache key independent of the relay's exact // fingerprint formula. // // Not persisted to disk — relay is the source of truth. On a fresh boot // the map is empty until each request triggers its first probe. import { createHash } from "crypto"; // Map. snapshot shape: // { creditsRemaining, tier, lastUpdated, lastError } const snapshots = new Map(); // LRU-ish defensive cap so a worst-case (e.g. a buggy probe spinning up // thousands of distinct identities) can't grow the map unbounded. In // normal use we expect on the order of (1 operator + N paid users) // rows, well under this ceiling. const MAX_SNAPSHOTS = 5000; const EMPTY_SNAPSHOT = Object.freeze({ creditsRemaining: null, tier: null, lastUpdated: null, lastError: null, }); // computeCreditKey({ installId, licenseKey, userId }) — derives the stable // per-identity key used by both the provider (when recording responses) // and the /api/relay/status handler (when looking up the right snapshot // for the request). Priority: cloud userId (core-decoupling `user:`) // → license (hashed raw key) → install_id. Returns null if all three are // missing (caller should treat as "no cache, never hit" and skip the // lookup). export function computeCreditKey({ installId, licenseKey, userId } = {}) { // Cloud (core-decoupling) identity: keyed by the Recaps account id, to // mirror the relay's `user:` pool. const uid = (userId || "").trim(); if (uid) return "user:" + uid; const lic = (licenseKey || "").trim(); if (lic) { return "lic:" + createHash("sha256").update(lic).digest("hex").slice(0, 16); } const id = (installId || "").trim(); if (id) return "inst:" + id; return null; } function getOrCreate(creditKey) { let s = snapshots.get(creditKey); if (!s) { if (snapshots.size >= MAX_SNAPSHOTS) { // Evict the oldest entry (Map iteration is insertion-ordered). const oldest = snapshots.keys().next().value; if (oldest != null) snapshots.delete(oldest); } s = { creditsRemaining: null, tier: null, lastUpdated: null, lastError: null }; snapshots.set(creditKey, s); } return s; } // Called by the relay provider on every response (including 4xx with the // standard envelope). `envelope` is the parsed JSON shape: // { credits_remaining, tier, ... } // `creditKey` is the per-identity key from computeCreditKey(); required // in multi-mode, optional in single-mode (falls through to a synthetic // "operator" bucket — see fallback below). export function updateRelayState(envelope, creditKey) { if (!envelope || typeof envelope !== "object") return; const key = creditKey || "operator"; const s = getOrCreate(key); if (typeof envelope.credits_remaining === "number") { s.creditsRemaining = envelope.credits_remaining; } if (typeof envelope.tier === "string") { s.tier = envelope.tier; } s.lastUpdated = Date.now(); s.lastError = null; } // Record a relay error (network failure, 5xx without envelope, etc.). // Same keying as updateRelayState — the error sticks to the identity // that hit it, so an admin's tile doesn't flash "relay unreachable" // because a tenant's probe failed. export function recordRelayError(message, creditKey) { const key = creditKey || "operator"; const s = getOrCreate(key); s.lastError = (message || "Unknown relay error").slice(0, 300); s.lastUpdated = Date.now(); } // getRelayState(creditKey) — fetch the snapshot for an identity. Returns // the EMPTY_SNAPSHOT (with nulls) if the identity has never recorded // anything yet. Caller can branch on creditsRemaining === null vs // numeric to decide whether to probe. export function getRelayState(creditKey) { const key = creditKey || "operator"; const s = snapshots.get(key); return s ? { ...s } : { ...EMPTY_SNAPSHOT }; } // Test/teardown helper. Clears every cached snapshot. Used by tests + a // debugging admin endpoint if we ever need one. Not used in production. export function resetRelayState() { snapshots.clear(); }