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