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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user