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:
@@ -0,0 +1,115 @@
|
||||
// Recap-side cache of the relay's GET /relay/capabilities response.
|
||||
// The relay tells us how large an audio file it can comfortably accept
|
||||
// FOR THIS SPECIFIC INSTALL given the relay's current routing config
|
||||
// AND this install's tier + Gemini-cap state. The same operator config
|
||||
// will route a fresh install through Gemini (chunk at 60min/30MB) and
|
||||
// a cap-exhausted install through hardware (no chunking) — so the
|
||||
// capabilities answer is per-install, not per-operator.
|
||||
//
|
||||
// Recap calls this in three places:
|
||||
// - on boot (warm the cache with safe defaults)
|
||||
// - hourly background refresh (catches operator config edits)
|
||||
// - inline before every relay-backed transcribe (so the chunking
|
||||
// decision matches the routing decision the relay will actually
|
||||
// make for this install RIGHT NOW)
|
||||
//
|
||||
// When the relay is unreachable, we fall back to Gemini-safe defaults
|
||||
// so chunking happens defensively for long audio.
|
||||
|
||||
import { getRelayBaseURL } from "./relay-default.js";
|
||||
import { getInstallId } from "./install-id.js";
|
||||
import { getRawLicenseKey } from "./license.js";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
// Safe defaults: chunk like we have been. Used until /relay/capabilities
|
||||
// successfully populates the cache, OR if the relay is unreachable.
|
||||
const DEFAULTS = Object.freeze({
|
||||
max_audio_mb: 30,
|
||||
max_audio_minutes: 60,
|
||||
preferred_chunk_seconds: 2700,
|
||||
// Audio-first ("walking mode") TTS availability. The relay advertises
|
||||
// whether ANY TTS backend (Kokoro on operator hardware, or ElevenLabs)
|
||||
// can serve a /relay/tts call. The frontend uses has_tts to decide
|
||||
// whether to show the "Listen" affordance; the prepare route checks it
|
||||
// before attempting synthesis. Conservative default: off until a fetch
|
||||
// confirms it.
|
||||
has_tts: false,
|
||||
tts_backend: null, // "kokoro" | "elevenlabs" | null
|
||||
tts_default_voice: null,
|
||||
reason: "default (relay unreachable or not yet fetched)",
|
||||
fetched_at: 0,
|
||||
});
|
||||
|
||||
let cached = { ...DEFAULTS };
|
||||
let refreshTimer = null;
|
||||
|
||||
export async function refreshRelayCapabilities() {
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) return cached;
|
||||
const url = `${base.replace(/\/$/, "")}/relay/capabilities`;
|
||||
// Send install-id + license so the relay can run the per-install
|
||||
// routing decision. Both are optional from the relay's perspective —
|
||||
// missing install-id falls back to operator-wide capabilities, which
|
||||
// is still safer than nothing.
|
||||
const headers = {};
|
||||
try {
|
||||
const installId = getInstallId();
|
||||
if (installId) headers["X-Recap-Install-Id"] = installId;
|
||||
} catch {}
|
||||
try {
|
||||
const licenseKey = getRawLicenseKey();
|
||||
if (licenseKey) headers["Authorization"] = `Bearer ${licenseKey}`;
|
||||
} catch {}
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!r.ok) {
|
||||
console.warn(`[relay-capabilities] ${url} returned HTTP ${r.status}`);
|
||||
return cached;
|
||||
}
|
||||
const data = await r.json();
|
||||
cached = {
|
||||
max_audio_mb:
|
||||
typeof data.max_audio_mb === "number" ? data.max_audio_mb : DEFAULTS.max_audio_mb,
|
||||
max_audio_minutes:
|
||||
typeof data.max_audio_minutes === "number"
|
||||
? data.max_audio_minutes
|
||||
: DEFAULTS.max_audio_minutes,
|
||||
preferred_chunk_seconds:
|
||||
data.preferred_chunk_seconds === null
|
||||
? null
|
||||
: typeof data.preferred_chunk_seconds === "number"
|
||||
? data.preferred_chunk_seconds
|
||||
: DEFAULTS.preferred_chunk_seconds,
|
||||
has_tts: !!data.has_tts,
|
||||
tts_backend: typeof data.tts_backend === "string" ? data.tts_backend : null,
|
||||
tts_default_voice:
|
||||
typeof data.tts_default_voice === "string" ? data.tts_default_voice : null,
|
||||
reason: typeof data.reason === "string" ? data.reason : null,
|
||||
fetched_at: Date.now(),
|
||||
};
|
||||
console.log(
|
||||
`[relay-capabilities] refreshed: ${cached.max_audio_mb}MB / ${cached.max_audio_minutes}min / chunk=${cached.preferred_chunk_seconds}s (${cached.reason})`
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`[relay-capabilities] fetch failed: ${err?.message || err}`);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function startRelayCapabilitiesRefresh() {
|
||||
// Fire-and-forget first refresh on boot; schedule hourly thereafter.
|
||||
refreshRelayCapabilities().catch(() => {});
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = setInterval(() => {
|
||||
refreshRelayCapabilities().catch(() => {});
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function getRelayCapabilities() {
|
||||
return cached;
|
||||
}
|
||||
Reference in New Issue
Block a user