0ae59f3550
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
116 lines
4.5 KiB
JavaScript
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;
|
|
}
|