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
+83 -3
View File
@@ -247,6 +247,30 @@ const LICENSE_OPEN_PREFIXES = [
"/api/library",
"/api/providers",
"/api/process",
// Audio-first ("walking mode") TTS. The /api/tts routes self-gate
// access (Max entitlement in multi mode; operator-only otherwise), so
// the blanket license middleware must let them through to that gate
// rather than 402-ing single-mode operators or Max users here.
"/api/tts",
// In-app purchase flow: GET /api/license/policies, POST
// /api/license/purchase, GET /api/license/poll/<invoiceId>. Buyers
// are unlicensed by definition — they must reach these before any
// license exists.
"/api/license/policies",
"/api/license/purchase",
"/api/license/poll",
// Relay credit top-up purchases: GET /api/credits/packages, POST
// /api/credits/buy, GET /api/credits/invoice/<id>. Buying credits
// doesn't require a license — Core (free) users should be able to
// top up just as easily as Pro/Max. The relay itself enforces
// billing via BTCPay; we just proxy.
"/api/credits",
// Self-serve subscription purchase: POST /api/billing/buy, GET
// /api/billing/status. A Core (free) user buying their way UP to
// Pro/Max is unlicensed by definition, so the activation gate must
// let them reach the buy + poll routes. The routes self-gate to a
// real signed-in user (req.user.id).
"/api/billing",
];
// ── Pro-tier feature gates ──────────────────────────────────────────────────
@@ -264,7 +288,7 @@ const PRO_FEATURE_GATES = [
entitlement: "subscriptions",
feature: "subscriptions",
message:
"Channel subscriptions and auto-queue require a paid license. Upgrade to unlock.",
"Channel subscriptions and auto-queue require a Pro or Max plan. Upgrade to unlock.",
},
];
@@ -286,7 +310,7 @@ export function setupLicenseMiddleware(app) {
message:
LIC.state === "licensed"
? "Your license is missing the 'pro' or 'max' entitlement. Contact the seller."
: "This feature requires a Recap license. Upgrade to unlock.",
: "This feature requires a Recaps license. Upgrade to unlock.",
state: LIC.state,
reason: LIC.reason,
activate_url: "/#activate",
@@ -299,6 +323,25 @@ export function setupLicenseMiddleware(app) {
app.use((req, res, next) => {
for (const gate of PRO_FEATURE_GATES) {
if (gate.prefixes.some((p) => req.path.startsWith(p))) {
// Multi mode (cloud): per-tenant — the user's relay-owned tier
// decides. Pro/Max (or the admin/operator) get in; free tenants get
// a clear 402. This is the per-tenant subscriptions gate.
if (req.recapMode === "multi") {
const tier = req.user?.tier;
if (
(req.user && req.user.is_admin) ||
tier === "pro" ||
tier === "max"
) {
return next();
}
return res.status(402).json({
error: "feature_not_in_tier",
feature: gate.feature,
message: gate.message,
});
}
// Single mode: the operator's own license carries the entitlement.
if (LIC.entitlements.has(gate.entitlement)) return next();
return res.status(402).json({
error: "feature_not_in_tier",
@@ -317,7 +360,44 @@ export function setupLicenseMiddleware(app) {
// Open by virtue of being in LICENSE_OPEN_PATHS — the gate lets them
// through unauthenticated.
export function setupLicenseRoutes(app) {
app.get("/api/license-status", (_req, res) => {
app.get("/api/license-status", (req, res) => {
// ── Multi-mode: return per-user view ────────────────────────────────
// The OPERATOR's license at /data/license.txt is "the install" — the
// pool that pays for free + trial users. Each signed-in cloud user
// has their own state:
// - paid user (users.tier = pro|max) → synthesize a license view
// from their relay-owned tier (core-decoupling: no Keysat license)
// - free tenant (signed in, Core tier) → unlicensed view (they're
// not Pro; their balance comes from tenant_credits via /relay/status)
// - anonymous/trial → unlicensed view (the badge should show trial
// credits, NOT the operator's PRO tier)
// - admin (is_admin = 1) → the operator's LIC (they ARE the
// operator; same UX as single mode)
if (req.recapMode === "multi") {
if (req.user && req.user.is_admin) {
// Operator viewing their own server: full operator license view.
return res.json(license.publicView(LIC));
}
// Paid cloud user — the tier is the relay-owned subscription tier,
// cached on the Recaps account (req.user.tier) and kept in sync by
// the operator grant flow. Synthesize a license view from it so the
// badge + per-user gates match a license-bearing user. Core-
// decoupling: this is the SOLE source of paid status in multi mode —
// a leftover per-user keysat_license is deliberately NOT consulted
// (licenses are moot in the cloud path), so the badge always agrees
// with the relay-owned tier shown in the operator's Tenants panel.
const tier = req.user?.tier;
if (req.user && (tier === "pro" || tier === "max")) {
return res.json(license.viewForTier(tier));
}
// Free tenant (tier core), trial, or fully anonymous — return an
// unlicensed view. Frontend uses this to hide the PRO badge / "manage
// license" affordances. Balance display comes from /api/relay/status
// (which is also multi-mode-aware).
return res.json(license.publicView(license.parseLicenseKey("")));
}
// ── Single-mode (the existing path) ─────────────────────────────────
// Opportunistic refresh: if the cached state is more than
// OPPORTUNISTIC_REFRESH_THRESHOLD_MS old, fire a validateOnline in
// the background. Doesn't block the response — the next status hit