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:
Keysat
2026-06-13 14:25:05 -05:00
parent db580abad7
commit 0ae59f3550
176 changed files with 23823 additions and 803 deletions
+101 -36
View File
@@ -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();
}