Files
recap/server/relay-capabilities.js
Keysat 0ae59f3550 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
2026-06-13 14:25:05 -05:00

116 lines
4.5 KiB
JavaScript

// 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;
}