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:
@@ -26,7 +26,7 @@ import { createWhisperProvider } from "./whisper.js";
|
||||
import { createRelayProvider } from "./relay.js";
|
||||
import { getInstallId } from "../install-id.js";
|
||||
import { getRawLicenseKey } from "../license.js";
|
||||
import { getRelayBaseURL } from "../relay-default.js";
|
||||
import { getRelayBaseURL, getRelayOperatorKey } from "../relay-default.js";
|
||||
|
||||
export const PROVIDER_NAMES = [
|
||||
"gemini",
|
||||
@@ -94,10 +94,14 @@ export function getProvider(name, opts = {}) {
|
||||
// `config` is the parsed startos-config.json snapshot.
|
||||
// `clientOpts` is { apiKey?, baseURL? } for THIS provider only —
|
||||
// typically a value out of req.body.providerOpts[name].
|
||||
// `req` is the Express request — only used for the relay provider in
|
||||
// multi-tenant mode, where the relay's install_id + license depend
|
||||
// on WHICH user is making the call. Pass it through whenever a
|
||||
// request is in scope. Single-mode + non-relay providers ignore it.
|
||||
//
|
||||
// Resolution priority for each field: client opt → config opt.
|
||||
// Returns { apiKey?, baseURL? } as appropriate for the provider.
|
||||
export function resolveProviderOpts(name, { config = {}, clientOpts = {} } = {}) {
|
||||
export function resolveProviderOpts(name, { config = {}, clientOpts = {}, req = null } = {}) {
|
||||
const fields = PROVIDER_KEY_FIELDS[name];
|
||||
if (!fields) {
|
||||
throw new Error(`Unknown provider: ${name}`);
|
||||
@@ -141,14 +145,66 @@ export function resolveProviderOpts(name, { config = {}, clientOpts = {} } = {})
|
||||
}
|
||||
}
|
||||
// Relay-specific injections: baseURL (hardcoded constant or env
|
||||
// override) + install-id (always) + license key (when present).
|
||||
// None of these come from clientOpts — relay identity + endpoint
|
||||
// must not be spoofable from a request body.
|
||||
// override) + install-id + license key. None of these come from
|
||||
// clientOpts — relay identity + endpoint must not be spoofable from
|
||||
// a request body.
|
||||
//
|
||||
// Identity rules:
|
||||
// - single mode: always the operator install + operator license
|
||||
// - multi mode + signed-in user WITH their own keysat_license:
|
||||
// user's synthetic_install_id + user's license. The relay's
|
||||
// license-keyed credit ledger (Path 3) routes consumption to
|
||||
// the right user-pool.
|
||||
// - multi mode + free / trial / Core user OR signed-in user with
|
||||
// no license: operator's install + license. Their relay calls
|
||||
// are paid out of the operator's credit pool (tenant_credits
|
||||
// gates them locally to control fan-out).
|
||||
if (name === "relay") {
|
||||
opts.baseURL = getRelayBaseURL();
|
||||
opts.installId = getInstallId();
|
||||
const rawKey = getRawLicenseKey();
|
||||
if (rawKey) opts.licenseKey = rawKey;
|
||||
const ident = pickRelayIdentity(req);
|
||||
if (ident.cloud) {
|
||||
// Core-decoupling cloud identity: authenticate the server with the
|
||||
// operator key + name the user; no per-user Keysat license.
|
||||
opts.cloud = true;
|
||||
opts.userId = ident.userId;
|
||||
opts.operatorKey = ident.operatorKey;
|
||||
} else {
|
||||
opts.installId = ident.installId;
|
||||
if (ident.licenseKey) opts.licenseKey = ident.licenseKey;
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
// pickRelayIdentity(req) — single source of truth for "which (install_id,
|
||||
// license) do we present to the relay for THIS request". Centralized so
|
||||
// the rule doesn't drift across the four resolveProviderOpts callsites.
|
||||
function pickRelayIdentity(req) {
|
||||
// Single mode (or no request in scope, e.g. boot-time relay capability
|
||||
// probe): operator identity, period.
|
||||
if (!req || req.recapMode !== "multi") {
|
||||
return { installId: getInstallId(), licenseKey: getRawLicenseKey() || null };
|
||||
}
|
||||
// Multi mode + PAID cloud user (core-decoupling): cloud identity —
|
||||
// authenticate the server with the operator key and name the user by
|
||||
// their Recaps account id. NO Keysat license; the relay owns the
|
||||
// tier, keyed by user-id. `req.user.tier` is the Recaps-side cache of
|
||||
// that relay tier (kept in sync by the operator grant flow). Falls
|
||||
// back to the operator pool when the operator key isn't configured.
|
||||
const tier = req.user?.tier;
|
||||
if (
|
||||
req.user &&
|
||||
req.user.id &&
|
||||
!req.user.is_admin &&
|
||||
(tier === "pro" || tier === "max")
|
||||
) {
|
||||
const operatorKey = getRelayOperatorKey();
|
||||
if (operatorKey) {
|
||||
return { cloud: true, userId: req.user.id, operatorKey };
|
||||
}
|
||||
}
|
||||
// Multi mode + everyone else (admin, anon trial, signed-in free user,
|
||||
// family-share tenant on a self-hosted multi-tenant operator's box):
|
||||
// pay out of the operator's pool.
|
||||
return { installId: getInstallId(), licenseKey: getRawLicenseKey() || null };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user