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
+70
View File
@@ -230,6 +230,42 @@ export function checkLicense() {
return base;
}
// parseLicenseKey(rawKey) — verify an arbitrary LIC1- string without
// touching disk or persisted state. Used in multi-tenant mode to build
// a publicView for a license stored on a user's row (users.keysat_license)
// rather than the operator's /data/license.txt. Returns the same state
// shape as checkLicense() so publicView(state) works uniformly.
//
// Differences from checkLicense:
// - Doesn't read /data/license.txt (rawKey is passed in)
// - Doesn't layer persisted online state (each user's license has
// its own online status; we'd need per-user state files to track
// that — out of scope for MVP, accept "offline-verified but
// online-unknown" as good enough)
export function parseLicenseKey(rawKey) {
if (verifierError) {
return emptyState({ state: "invalid", reason: `bad embedded key: ${verifierError}` });
}
const raw = (rawKey || "").trim();
if (!raw || !raw.startsWith("LIC1-")) return emptyState();
try {
const ok = verifier.verify(raw);
const payload = ok.payload || {};
if (payload.productSlug && payload.productSlug !== PRODUCT_SLUG) {
return emptyState({ state: "invalid", reason: "product_mismatch" });
}
return emptyState({
state: "licensed",
licenseId: payload.licenseUuid || null,
entitlements: new Set(payload.entitlements || []),
expiresAt: payload.expiresAt ? new Date(payload.expiresAt * 1000) : null,
isTrial: !!(payload.flags & 1),
});
} catch (e) {
return emptyState({ state: "invalid", reason: e?.message || "verify_failed" });
}
}
// activate(rawKey) — write a pasted key to disk, then re-check.
// Returns the new license state. Throws on bad input format only;
// signature failures surface as state: 'invalid' with a reason.
@@ -401,6 +437,40 @@ export function publicView(state) {
};
}
// viewForTier(tier, opts) — a publicView-shaped object synthesized from a
// relay-owned subscription tier ("pro" | "max"), for core-decoupling cloud
// users who have NO Keysat license. The entitlement sets mirror the Pro/Max
// license keys documented at the top of this file, so the frontend badge AND
// the per-user feature gates behave identically to a license-bearing user.
// Anything other than "pro"/"max" yields an unlicensed view (no paid
// entitlements). `expiresAt` is an optional ISO string (the relay owns the
// authoritative expiry; Recaps caches only the tier, so this is usually null).
export function viewForTier(tier, { expiresAt = null } = {}) {
const t = (tier || "").toLowerCase();
let entitlements;
if (t === "max") {
entitlements = ["max", "subscriptions", "relay_max"];
} else if (t === "pro") {
entitlements = ["pro", "subscriptions", "relay_pro"];
} else {
entitlements = [];
}
return {
state: entitlements.length ? "licensed" : "unlicensed",
reason: null,
licenseId: null,
entitlements: entitlements.sort(),
expiresAt: expiresAt || null,
isTrial: false,
productSlug: PRODUCT_SLUG,
keysatBaseUrl: KEYSAT_BASE_URL,
licensePath: LICENSE_PATH,
lastValidatedAt: null,
serverStatus: null,
graceUntil: null,
};
}
// has(state, entitlement) — convenience wrapper for feature gates.
export function has(state, entitlement) {
return state && state.entitlements && state.entitlements.has(entitlement);