Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
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
This commit is contained in:
+101
-36
@@ -1,52 +1,117 @@
|
||||
// In-memory cache of the most recent relay-reported credit balance + tier.
|
||||
// Updated every time a relay provider call lands (success or 4xx error
|
||||
// that includes the standard envelope). Exposed via /api/relay/status
|
||||
// so the UI can render the "N credits remaining · Tier: X" banner
|
||||
// without re-hitting the relay just for status.
|
||||
// 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.
|
||||
//
|
||||
// Not persisted to disk — the relay is the source of truth. We just cache
|
||||
// the last response so the UI doesn't have to wait for the next request
|
||||
// to refresh the display. On a fresh boot the cache is empty until the
|
||||
// first /api/relay/status call, which can optionally probe the relay.
|
||||
// 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.
|
||||
|
||||
let lastSnapshot = {
|
||||
creditsRemaining: null, // number | null
|
||||
tier: null, // "core" | "pro" | "max" | null
|
||||
lastUpdated: null, // ms-epoch | null
|
||||
lastError: null, // string | null
|
||||
};
|
||||
import { createHash } from "crypto";
|
||||
|
||||
// Called by the relay provider on every response (including error
|
||||
// responses that the relay annotated with the standard envelope).
|
||||
// `envelope` is the parsed JSON shape: { credits_remaining, tier, ... }.
|
||||
export function updateRelayState(envelope) {
|
||||
// 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") {
|
||||
lastSnapshot.creditsRemaining = envelope.credits_remaining;
|
||||
s.creditsRemaining = envelope.credits_remaining;
|
||||
}
|
||||
if (typeof envelope.tier === "string") {
|
||||
lastSnapshot.tier = envelope.tier;
|
||||
s.tier = envelope.tier;
|
||||
}
|
||||
lastSnapshot.lastUpdated = Date.now();
|
||||
lastSnapshot.lastError = null;
|
||||
s.lastUpdated = Date.now();
|
||||
s.lastError = null;
|
||||
}
|
||||
|
||||
// Record a relay error (network failure, 5xx with no envelope, etc.).
|
||||
// Surfaced in the UI status so the user knows the balance display is stale.
|
||||
export function recordRelayError(message) {
|
||||
lastSnapshot.lastError = (message || "Unknown relay error").slice(0, 300);
|
||||
lastSnapshot.lastUpdated = Date.now();
|
||||
// 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();
|
||||
}
|
||||
|
||||
export function getRelayState() {
|
||||
return { ...lastSnapshot };
|
||||
// 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() {
|
||||
lastSnapshot = {
|
||||
creditsRemaining: null,
|
||||
tier: null,
|
||||
lastUpdated: null,
|
||||
lastError: null,
|
||||
};
|
||||
snapshots.clear();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user