0ae59f3550
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
118 lines
4.6 KiB
JavaScript
118 lines
4.6 KiB
JavaScript
// 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:<sha256(licenseKey).slice(0, 16)>` for paid licenses,
|
|
// `inst:<installId>` 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<creditKey, snapshot>. 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:<id>`)
|
|
// → 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:<id>` 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();
|
|
}
|