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:
Keysat
2026-06-13 14:25:05 -05:00
parent db580abad7
commit 0ae59f3550
176 changed files with 23823 additions and 803 deletions
+821 -14
View File
@@ -32,7 +32,12 @@
import { createReadStream } from "fs";
import { retryAPI, formatTime } from "../util.js";
import { zeroCost } from "./cost.js";
import { updateRelayState, recordRelayError } from "../relay-state.js";
import {
updateRelayState,
recordRelayError,
computeCreditKey,
} from "../relay-state.js";
import { getRelayBaseURL, getRelayOperatorKey } from "../relay-default.js";
// Provider name shown in logs + chunk pagination labels. "relay" rather
// than e.g. "keysat-relay" because operators may run their own relay
@@ -49,6 +54,12 @@ export function createRelayProvider({
baseURL,
installId,
licenseKey,
// Core-decoupling cloud identity: when `cloud` is set, the relay call
// authenticates the SERVER with `operatorKey` and names the user via
// `userId` (X-Recap-User-Id) instead of carrying a per-user license.
cloud = false,
userId = null,
operatorKey = null,
timeoutMs = 900_000,
} = {}) {
if (!baseURL) {
@@ -56,23 +67,44 @@ export function createRelayProvider({
"createRelayProvider: baseURL is required (e.g. https://relay.keysat.xyz)"
);
}
if (!installId) {
if (cloud) {
if (!userId || !operatorKey) {
throw new Error(
"createRelayProvider: cloud identity requires userId + operatorKey"
);
}
} else if (!installId) {
throw new Error(
"createRelayProvider: installId is required (boot must initInstallId first)"
);
}
const base = baseURL.replace(/\/$/, "");
// Per-identity credit-key for the relay-state cache. Computed once
// here because (installId, licenseKey) are fixed for this provider
// instance — every subsequent updateRelayState/recordRelayError on
// this instance routes to the same cache slot. Multi-mode creates a
// fresh provider per request (resolveProviderOpts injects per-user
// identity), so each user's relay state stays isolated.
const creditKey = computeCreditKey({
installId,
licenseKey,
userId: cloud ? userId : null,
});
// Build the auth/identity headers attached to every relay call.
// job_id is optional but the orchestration layer should always pass
// one — without it the relay can't bundle the transcribe + analyze
// pair into a single credit charge.
function buildHeaders({ extra = {}, jobId } = {}) {
const h = {
"X-Recap-Install-Id": installId,
...extra,
};
if (licenseKey) h["Authorization"] = `Bearer ${licenseKey}`;
const h = { ...extra };
if (cloud) {
h["X-Recap-User-Id"] = userId;
h["X-Recap-Operator-Key"] = operatorKey;
} else {
h["X-Recap-Install-Id"] = installId;
if (licenseKey) h["Authorization"] = `Bearer ${licenseKey}`;
}
if (jobId) h["X-Recap-Job-Id"] = jobId;
return h;
}
@@ -81,6 +113,39 @@ export function createRelayProvider({
// response (success or failure) carries the standard envelope so
// Recap can keep its balance display accurate even on errors. We
// try to parse error bodies to harvest that.
// GET wrapper mirroring postRelay's envelope-aware error handling.
// Used by the transcribe-url poll loop to fetch job status.
async function getRelay({ path, headers, signal }) {
let res;
try {
res = await fetch(`${base}${path}`, { method: "GET", headers, signal });
} catch (err) {
recordRelayError(err?.message || String(err), creditKey);
throw err;
}
const text = await res.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {}
if (parsed && (typeof parsed.credits_remaining === "number" || parsed.tier)) {
updateRelayState(parsed, creditKey);
}
if (!res.ok) {
const msg =
parsed?.error ||
parsed?.message ||
text?.slice(0, 300) ||
`HTTP ${res.status}`;
const err = new Error(`Relay GET ${path} ${res.status}: ${msg}`);
err.status = res.status;
err.envelope = parsed;
if (!parsed) recordRelayError(msg, creditKey);
throw err;
}
return parsed;
}
async function postRelay({ path, body, headers, signal }) {
let res;
try {
@@ -91,7 +156,7 @@ export function createRelayProvider({
signal,
});
} catch (err) {
recordRelayError(err?.message || String(err));
recordRelayError(err?.message || String(err), creditKey);
throw err;
}
const text = await res.text();
@@ -100,7 +165,7 @@ export function createRelayProvider({
parsed = text ? JSON.parse(text) : null;
} catch {}
if (parsed && (typeof parsed.credits_remaining === "number" || parsed.tier)) {
updateRelayState(parsed);
updateRelayState(parsed, creditKey);
}
if (!res.ok) {
const msg =
@@ -111,7 +176,7 @@ export function createRelayProvider({
const err = new Error(`Relay ${path} ${res.status}: ${msg}`);
err.status = res.status;
err.envelope = parsed;
if (!parsed) recordRelayError(msg);
if (!parsed) recordRelayError(msg, creditKey);
throw err;
}
return parsed;
@@ -136,6 +201,524 @@ export function createRelayProvider({
return [...RELAY_TRANSCRIPTION_MODELS];
},
// POST /relay/transcribe-url — like transcribeAudio but the
// relay fetches the audio from the URL itself (yt-dlp for YouTube,
// direct HTTP for podcast RSS audio). Saves the buyer's upload
// bandwidth, which is often the slowest leg of the pipeline.
//
// The relay processes this asynchronously: the POST returns
// immediately with a job_id, then we poll GET /relay/jobs/{id}
// until status flips to "complete" or "failed". Async pattern is
// required because no proxy / load balancer in the path can be
// trusted to keep a multi-minute HTTP request alive — short poll
// requests, by contrast, are bulletproof.
async transcribeUrl({
mediaUrl,
mediaType, // "youtube" | "podcast" (optional; relay sniffs URL shape)
mimeType,
titleHint,
channelHint = "",
descriptionHint = "",
chaptersHint = [],
onProgress = () => {},
signal,
jobId,
}) {
onProgress(`Asking relay to fetch + transcribe ${mediaUrl.slice(0, 80)}...`);
const start = Date.now();
// Step 1: kick off the job. retryAPI handles transient transport
// errors on this short request.
const initEnvelope = await retryAPI(
() =>
postRelay({
path: "/relay/transcribe-url",
body: JSON.stringify({
media_url: mediaUrl,
type: mediaType || undefined,
mime_type: mimeType || undefined,
title: titleHint || undefined,
channel: channelHint || undefined,
description: descriptionHint || undefined,
chapters:
Array.isArray(chaptersHint) && chaptersHint.length > 0
? chaptersHint
: undefined,
}),
headers: buildHeaders({
extra: { "Content-Type": "application/json" },
jobId,
}),
signal,
}),
{
retries: 2,
delayMs: 5000,
label: "Relay transcribe-url (kickoff)",
log: (msg) => onProgress(msg),
}
);
const kickoffResult = initEnvelope.result || {};
const backgroundJobId = kickoffResult.job_id;
if (!backgroundJobId) {
throw new Error(
"Relay transcribe-url didn't return a job_id — old relay version? Re-install relay 0.2.14 or newer."
);
}
onProgress(
`Relay accepted job ${backgroundJobId.slice(0, 8)}… processing in background`
);
// Step 2: poll GET /relay/jobs/{id} until complete or failed.
// Generous max-wait — relay transcribes for long audio can run
// several minutes; we want to wait through that, not give up
// prematurely. The poll requests themselves are cheap, so the
// cost of a long wait is just time-on-the-clock, not bandwidth.
const POLL_INTERVAL_MS = 5_000;
const MAX_WAIT_MS = 30 * 60 * 1000; // 30 min
const deadline = Date.now() + MAX_WAIT_MS;
let lastProgress = null;
let pollFailuresInARow = 0;
const MAX_CONSECUTIVE_POLL_FAILURES = 6; // ~30s of poll outage
let envelope = null;
while (true) {
if (signal?.aborted) {
throw new Error("Relay job polling aborted");
}
if (Date.now() > deadline) {
throw new Error(
`Relay transcribe-url did not complete within ${Math.round(
MAX_WAIT_MS / 60_000
)} minutes — giving up`
);
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
try {
envelope = await getRelay({
path: `/relay/jobs/${encodeURIComponent(backgroundJobId)}`,
headers: buildHeaders({ jobId }),
signal,
});
pollFailuresInARow = 0;
} catch (err) {
// A 404 specifically means the relay no longer knows about
// this job. That's not a network blip — the relay almost
// certainly restarted (operator update, crash, manual
// restart) and lost in-memory job state. Surface
// immediately rather than burning 6 retries; the orchestrator's
// fallback logic decides whether to retry from scratch.
const status = err?.status || 0;
const msg = err?.message || String(err);
if (status === 404 || /job_not_found/.test(msg)) {
throw new Error(
`Relay lost the job (probably restarted) — start over to retry`
);
}
// Other failures (network, TLS, timeout) are transient.
// Retry up to MAX_CONSECUTIVE_POLL_FAILURES before giving up.
pollFailuresInARow += 1;
if (pollFailuresInARow >= MAX_CONSECUTIVE_POLL_FAILURES) {
throw new Error(
`Relay polling lost — ${pollFailuresInARow} consecutive failures: ${msg}`
);
}
onProgress(
`Relay poll glitch (${pollFailuresInARow}/${MAX_CONSECUTIVE_POLL_FAILURES}): ${msg.slice(0, 100)}`
);
continue;
}
const jobRes = envelope.result || {};
if (jobRes.progress && jobRes.progress !== lastProgress) {
lastProgress = jobRes.progress;
onProgress(`Relay: ${jobRes.progress}`);
}
if (jobRes.status === "complete") break;
if (jobRes.status === "failed") {
throw new Error(
jobRes.error || "Relay transcribe-url job failed (no detail)"
);
}
// "queued" / "running" → keep polling
}
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
const remaining =
typeof envelope.credits_remaining === "number"
? `, ${envelope.credits_remaining} credits left`
: "";
onProgress(`Relay transcribe-url complete in ${elapsed}s${remaining}`);
// The job's result field carries the transcribe backend's
// output verbatim — same shape as the (sync) transcribeAudio
// result. Walk segments → bracketed text the same way.
const innerResult = envelope.result?.result || {};
const segments = Array.isArray(innerResult.segments)
? innerResult.segments
: [];
const lines = segments.length
? segments.map(
(s) => `[${formatTime(s.start || 0)}] ${(s.text || "").trim()}`
)
: [`[0:00] ${(innerResult.text || "").trim()}`];
const text = lines.join("\n");
const cost = zeroCost({
inputTokens: 0,
outputTokens: 0,
thinkingTokens: 0,
});
return {
text,
usage: { inputTokens: 0, outputTokens: 0, thinkingTokens: 0, totalTokens: 0 },
cost,
finishReason: null,
blockReason: "none",
raw: envelope,
};
},
// POST /relay/summarize-url — combined transcribe+analyze pipeline
// that streams per-window section results back over SSE. Used in
// "Recap Relay" mode where the user has chosen the operator's
// relay for the WHOLE pipeline (not per-step). Replaces the
// old transcribeUrl + per-window analyzeText fan-out with a single
// server-side pipeline, saving ~12 round-trips per long video and
// letting the operator's Settings-tab chunking knobs actually
// drive production behavior (instead of just benchmarks).
//
// Flow:
// 1. POST /relay/summarize-url → returns job_id immediately
// 2. GET /relay/summarize-url/:jobId/events (SSE) → stream
// transcribe_complete + window_complete + done events
// 3. onProgress / onWindowComplete callbacks fire as events
// arrive (mirrors the recap-app's chunked-analyze.js shape
// so the existing UI rendering code keeps working)
// 4. Returns the final { transcript, sections } envelope when
// "done" arrives. Throws on "error" or stream close before
// done.
//
// Falls back to one-shot poll-based completion if SSE never
// connects (e.g. operator's reverse proxy strips text/event-stream
// — observed with overly-aggressive content-type filters).
async summarizeUrl({
mediaUrl,
mediaType,
mimeType,
titleHint,
channelHint = "",
descriptionHint = "",
chaptersHint = [],
onProgress = () => {},
onWindowComplete = null,
// Fires when the relay's SSE stream emits transcribe_complete.
// The full transcript text is available BEFORE any analyze
// window completes (analyze runs after transcribe finishes),
// so subscribing here lets the caller parse the transcript
// into entries and have them ready in time for the FIRST
// window_complete callback. Used by Recap-app's relay-mode
// branch to stream per-window section chunks to the browser
// incrementally — without parsed entries the chunks can\'t be
// assembled, so this callback is the dependency that unblocks
// streaming.
onTranscribeComplete = null,
signal,
jobId,
}) {
onProgress(`Asking relay to summarize ${mediaUrl.slice(0, 80)}...`);
const start = Date.now();
// Step 1: kick off the job.
const initEnvelope = await retryAPI(
() =>
postRelay({
path: "/relay/summarize-url",
body: JSON.stringify({
media_url: mediaUrl,
type: mediaType || undefined,
mime_type: mimeType || undefined,
title: titleHint || undefined,
channel: channelHint || undefined,
description: descriptionHint || undefined,
chapters:
Array.isArray(chaptersHint) && chaptersHint.length > 0
? chaptersHint
: undefined,
}),
headers: buildHeaders({
extra: { "Content-Type": "application/json" },
jobId,
}),
signal,
}),
{
retries: 2,
delayMs: 5000,
label: "Relay summarize-url (kickoff)",
log: (msg) => onProgress(msg),
}
);
const kickoffResult = initEnvelope.result || {};
const backgroundJobId = kickoffResult.job_id;
if (!backgroundJobId) {
throw new Error(
"Relay summarize-url didn't return a job_id — old relay version? Re-install relay 0.2.33 or newer."
);
}
onProgress(
`Relay accepted job ${backgroundJobId.slice(0, 8)}… streaming`
);
// Step 2: open SSE stream for live events.
// We use fetch + manual SSE parsing rather than EventSource
// because (a) Node's global EventSource is recent (24+), (b)
// we need custom auth headers which EventSource doesn't support.
let sseRes;
try {
sseRes = await fetch(
`${base}/relay/summarize-url/${encodeURIComponent(backgroundJobId)}/events`,
{
method: "GET",
headers: buildHeaders({
extra: { Accept: "text/event-stream" },
jobId,
}),
signal,
}
);
} catch (err) {
recordRelayError(err?.message || String(err), creditKey);
throw new Error(
`Relay summarize-url SSE connect failed: ${err?.message || err}`
);
}
if (!sseRes.ok || !sseRes.body) {
throw new Error(
`Relay summarize-url SSE returned ${sseRes.status} ${sseRes.statusText || ""}`.trim()
);
}
// Verify the server actually returned an event stream. Some
// reverse proxies silently rewrite the content-type which
// breaks SSE without raising an HTTP error.
const ct = sseRes.headers.get("content-type") || "";
if (!ct.includes("text/event-stream")) {
throw new Error(
`Relay summarize-url SSE expected text/event-stream, got "${ct}" — check your reverse proxy config`
);
}
// Step 3: parse SSE frames as they arrive, dispatch to callbacks.
// SSE frame syntax: blocks separated by \n\n, each block is a
// sequence of "field: value" lines. We collect event/data/id
// pairs and fire on each completed frame.
const reader = sseRes.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let finalResult = null;
let finalError = null;
let transcriptText = null;
const sectionsByWindow = new Map(); // windowIdx → owned sections
let totalWindows = 0;
let windowsDone = 0;
const handleFrame = (frame) => {
if (!frame || !frame.trim()) return;
let eventType = "message";
let dataStr = "";
for (const rawLine of frame.split(/\r?\n/)) {
if (!rawLine || rawLine.startsWith(":")) continue;
const colon = rawLine.indexOf(":");
if (colon < 0) continue;
const field = rawLine.slice(0, colon).trim();
const value = rawLine.slice(colon + 1).replace(/^ /, "");
if (field === "event") eventType = value;
else if (field === "data") dataStr += (dataStr ? "\n" : "") + value;
}
if (!dataStr) return;
let data;
try { data = JSON.parse(dataStr); }
catch { return; }
if (eventType === "progress") {
if (data.message) onProgress(`Relay: ${data.message}`);
} else if (eventType === "transcribe_complete") {
transcriptText = data.transcript || "";
onProgress(
`Relay transcribe done — ${data.chunk_count ?? "?"} chunks, ${Math.round((data.audio_seconds || 0) / 60)} min audio`
);
if (onTranscribeComplete) {
try {
onTranscribeComplete({
transcript: transcriptText,
chunk_count: data.chunk_count ?? null,
audio_seconds: data.audio_seconds ?? null,
model: data.model || null,
});
} catch (cbErr) {
onProgress(`transcribe_complete callback error: ${cbErr?.message || cbErr}`);
}
}
} else if (eventType === "window_complete") {
totalWindows = data.totalWindows || totalWindows;
sectionsByWindow.set(data.windowIdx, data.ownedSections || []);
windowsDone += 1;
if (onWindowComplete) {
try {
onWindowComplete({
windowIdx: data.windowIdx,
totalWindows: data.totalWindows,
ownedSections: data.ownedSections || [],
// Pipelined mode (relay v0.2.89+) attaches the
// window's own entries here. Sequential mode (older
// relays OR Gemini-transcribe path) omits the field,
// which signals to the caller to fall back to the
// global streamedRelayEntries cache populated by
// onTranscribeComplete.
windowEntries: Array.isArray(data.windowEntries)
? data.windowEntries
: null,
});
} catch (cbErr) {
// Surface to caller log but don't kill the stream.
onProgress(`window_complete callback error: ${cbErr?.message || cbErr}`);
}
}
onProgress(`Relay analyze: ${windowsDone}/${data.totalWindows} windows complete`);
} else if (eventType === "done") {
// Relay versions <= 0.2.59 emitted the SSE done event with a
// double-nested shape — markComplete put the whole envelope
// (`{result: {inner}, credit_charged, tier}`) into the event,
// so `data.result` was the envelope and the actual fields
// (title, transcript, analyze_model) lived at
// `data.result.result.*`. Relay 0.2.60+ unwraps before
// emitting, so `data.result.title` is correct directly.
// Detect the old shape by checking if `data.result.result`
// exists and looks like the inner object (has the keys we
// expect to find at the top of `data.result`). Unwrap once
// when present. Backwards-compatible — works against any
// relay version.
const raw = data.result || {};
if (
raw && typeof raw === "object" &&
raw.result && typeof raw.result === "object" &&
("transcript" in raw.result ||
"analysis" in raw.result ||
"title" in raw.result)
) {
finalResult = raw.result;
} else {
finalResult = raw;
}
} else if (eventType === "error") {
finalError = new Error(data.error || "relay summarize-url failed");
}
};
try {
while (true) {
if (signal?.aborted) {
try { reader.cancel(); } catch {}
throw new Error("Relay summarize-url aborted");
}
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Frames are separated by blank lines.
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
handleFrame(frame);
}
if (finalResult || finalError) {
try { reader.cancel(); } catch {}
break;
}
}
} finally {
try { reader.releaseLock(); } catch {}
}
if (finalError) throw finalError;
if (!finalResult) {
throw new Error(
"Relay summarize-url SSE closed before 'done' event — connection dropped mid-flight"
);
}
// The "done" event carries the final stitched analysis result.
// Stitch our own ordered sections list from sectionsByWindow as
// a defensive fallback — but trust finalResult.analysis.sections
// when present (it's the relay's authoritative stitch).
const stitchedAnalysis =
(finalResult.analysis && Array.isArray(finalResult.analysis.sections))
? finalResult.analysis
: {
sections: [...sectionsByWindow.keys()]
.sort((a, b) => a - b)
.flatMap((k) => sectionsByWindow.get(k) || []),
};
// Also refresh the relay state with the final envelope's
// credit balance so the picker's "credits remaining" pill
// updates without a separate /api/relay/status round-trip.
// (initEnvelope already had a current snapshot; final state
// applies after the job completed.)
if (typeof initEnvelope.credits_remaining === "number") {
updateRelayState(initEnvelope, creditKey);
}
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
onProgress(`Relay summarize-url complete in ${elapsed}s`);
return {
// Transcript in bracketed [MM:SS] form (same shape as
// transcribeUrl returns), for downstream code paths that
// still parse text into entries.
transcript: transcriptText || finalResult.transcript || "",
// Final stitched analysis as JSON { sections: [...] } with
// GLOBAL startIndex / endIndex into the transcript entries.
analysis: stitchedAnalysis,
// Resolved media title — either the operator-supplied hint or
// the title yt-dlp extracted from YouTube during download.
// Older relays (< 0.2.53) don't include this field; the
// caller falls back to its own titleSurrogate when it's null.
title: finalResult.title || null,
// Model attribution + timing diagnostics.
transcribe_model: finalResult.transcribe_model || null,
analyze_model: finalResult.analyze_model || null,
audio_seconds: finalResult.audio_seconds || null,
audio_bytes: finalResult.audio_bytes || null,
wall_time_ms: finalResult.wall_time_ms || null,
chunk_count: finalResult.chunk_count || null,
analyze_windows: finalResult.analyze_windows || totalWindows,
analyze_windows_failed: finalResult.analyze_windows_failed || 0,
// Phase 1D — speaker diarization output from operator
// hardware. Both null on relays < 0.2.88 OR when diarization
// was off OR when no fingerprints could be collected. When
// present:
// speakers — map { Speaker_A: { turns, total_speaking_seconds,
// mean_confidence, chunks_appeared_in,
// fingerprint_count }, ... }
// transcript_segments — array of { start, end, text, speaker,
// speaker_confidence } at the RAW Parakeet
// segment granularity (finer than the
// readable `transcript` text). Recap's
// UI matches these by time against the
// merged entries to color each line.
speakers: finalResult.speakers || null,
transcript_segments: Array.isArray(finalResult.transcript_segments)
? finalResult.transcript_segments
: null,
// Phase 2 — speaker name map from the relay's post-cluster
// polish pass. Null when polish was skipped or all names
// returned null. Recap renders these inline with the
// speakers legend (showing "Matt Hill · 24:42" instead of
// "Speaker A · 24:42").
speaker_names: finalResult.speaker_names || null,
};
},
async transcribeAudio({
filePath,
mimeType,
@@ -248,7 +831,7 @@ export function createRelayProvider({
parsed = text ? JSON.parse(text) : null;
} catch {}
if (parsed && (typeof parsed.credits_remaining === "number" || parsed.tier)) {
updateRelayState(parsed);
updateRelayState(parsed, creditKey);
}
if (!res.ok) {
const msg =
@@ -256,7 +839,7 @@ export function createRelayProvider({
parsed?.message ||
text?.slice(0, 300) ||
`HTTP ${res.status}`;
recordRelayError(msg);
recordRelayError(msg, creditKey);
const err = new Error(`Relay /balance ${res.status}: ${msg}`);
err.status = res.status;
throw err;
@@ -264,9 +847,9 @@ export function createRelayProvider({
return parsed;
} catch (err) {
if (err?.name === "AbortError") {
recordRelayError(`balance ping timed out after ${timeoutMs}ms`);
recordRelayError(`balance ping timed out after ${timeoutMs}ms`, creditKey);
} else if (!err.status) {
recordRelayError(err?.message || String(err));
recordRelayError(err?.message || String(err), creditKey);
}
throw err;
} finally {
@@ -326,9 +909,233 @@ export function createRelayProvider({
raw: envelope,
};
},
// Text-to-speech for the audio-first ("walking mode") player. Unlike
// the other methods this returns BINARY audio (mp3 by default), with
// credit/balance metadata in response HEADERS rather than a JSON
// envelope — so it can't reuse postRelay. Mirrors postRelay's
// error-envelope harvesting + relay-state update on the JSON error
// path. The caller passes ONE jobId for a whole recap so the relay
// charges at most 1 credit for synthesizing all its topics.
async tts({ text, voice, format = "mp3", jobId, signal } = {}) {
const headers = buildHeaders({
extra: { "Content-Type": "application/json" },
jobId,
});
let res;
try {
// Per-clip timeout so one hung synth (e.g. Spark Control busy
// transcribing the subscription queue) can't stall the whole
// sequential prepare loop — the caller catches and moves on to the
// next topic. 90s comfortably exceeds the relay's own ~60s Kokoro
// timeout, so the relay's clean error wins when it's the slow one.
res = await fetch(`${base}/relay/tts`, {
method: "POST",
headers,
body: JSON.stringify({ text, voice, format }),
signal: signal || AbortSignal.timeout(90_000),
});
} catch (err) {
recordRelayError(err?.message || String(err), creditKey);
throw err;
}
if (!res.ok) {
// Errors carry the standard JSON envelope — harvest balance + msg.
const errText = await res.text().catch(() => "");
let parsed = null;
try {
parsed = errText ? JSON.parse(errText) : null;
} catch {}
if (parsed && (typeof parsed.credits_remaining === "number" || parsed.tier)) {
updateRelayState(parsed, creditKey);
}
const msg =
parsed?.error || parsed?.message || errText?.slice(0, 300) || `HTTP ${res.status}`;
const err = new Error(`Relay /relay/tts ${res.status}: ${msg}`);
err.status = res.status;
err.envelope = parsed;
if (!parsed) recordRelayError(msg, creditKey);
throw err;
}
const audio = Buffer.from(await res.arrayBuffer());
// Success path: credit state lives in headers. "unlimited" (Max) →
// null, matching the JSON envelope's null credits_remaining.
const creditsHdr = res.headers.get("X-Recap-Credits-Remaining");
const tier = res.headers.get("X-Recap-Tier");
const creditCharged = Number(res.headers.get("X-Recap-Credit-Charged") || 0);
const creditsRemaining =
creditsHdr == null ? null : creditsHdr === "unlimited" ? null : Number(creditsHdr);
if (tier || typeof creditsRemaining === "number") {
updateRelayState(
{ credits_remaining: creditsRemaining, tier, credit_charged: creditCharged },
creditKey,
);
}
const durHdr = res.headers.get("X-Recap-Audio-Duration");
return {
audio,
contentType: res.headers.get("Content-Type") || "audio/mpeg",
voice: res.headers.get("X-Recap-Tts-Voice") || voice || null,
backend: res.headers.get("X-Recap-Tts-Backend") || null,
creditCharged,
durationSeconds: durHdr ? Number(durHdr) : null,
};
},
};
}
// ── Operator → relay: set / read a cloud user's tier (core-decoupling) ──
// The relay is the source of truth for cloud Pro/Max tiers. The operator
// grant flow calls these server-to-server, authenticated by the shared
// operator key — no per-user license involved.
export async function setRelayUserTier({ userId, tier, expiresAt = null, timeoutMs = 10000 }) {
const base = getRelayBaseURL();
const operatorKey = getRelayOperatorKey();
if (!base) throw new Error("relay base URL not configured");
if (!operatorKey) {
throw new Error("operator key not configured (set RECAP_RELAY_OPERATOR_KEY)");
}
const res = await fetch(`${base.replace(/\/$/, "")}/relay/user-tier`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Recap-Operator-Key": operatorKey },
body: JSON.stringify({ user_id: userId, tier, expires_at: expiresAt || undefined }),
signal: AbortSignal.timeout(timeoutMs),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data?.error || `relay user-tier ${res.status}`);
err.status = res.status;
throw err;
}
return data;
}
export async function getRelayUserTier({ userId, timeoutMs = 8000 }) {
const base = getRelayBaseURL();
const operatorKey = getRelayOperatorKey();
if (!base || !operatorKey) return null;
try {
const res = await fetch(
`${base.replace(/\/$/, "")}/relay/user-tier/${encodeURIComponent(userId)}`,
{ headers: { "X-Recap-Operator-Key": operatorKey }, signal: AbortSignal.timeout(timeoutMs) }
);
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
// Ask the relay to create a BTCPay invoice for a prepaid Pro/Max period for
// `userId`. Operator-key authed (server-to-server). Returns
// { invoice_id, checkout_url, sats, tier, period_days } or throws.
export async function createRelayTierInvoice({
userId,
tier,
returnUrl = null,
timeoutMs = 12000,
}) {
const base = getRelayBaseURL();
const operatorKey = getRelayOperatorKey();
if (!base) throw new Error("relay base URL not configured");
if (!operatorKey) {
throw new Error("operator key not configured (set RECAP_RELAY_OPERATOR_KEY)");
}
const res = await fetch(`${base.replace(/\/$/, "")}/relay/tier-invoice`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Recap-Operator-Key": operatorKey },
body: JSON.stringify({ user_id: userId, tier, return_url: returnUrl || undefined }),
signal: AbortSignal.timeout(timeoutMs),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data?.error || `relay tier-invoice ${res.status}`);
err.status = res.status;
throw err;
}
return data;
}
// Ask the relay to create a Zaprite (card) hosted-checkout order for a
// prepaid Pro/Max period for `userId`. Operator-key authed (server-to-
// server), mirroring createRelayTierInvoice but for the card rail. Returns
// { order_id, checkout_url, amount, currency, tier, period_days } or throws.
export async function createRelayZapriteOrder({
userId,
tier,
returnUrl = null,
timeoutMs = 12000,
}) {
const base = getRelayBaseURL();
const operatorKey = getRelayOperatorKey();
if (!base) throw new Error("relay base URL not configured");
if (!operatorKey) {
throw new Error("operator key not configured (set RECAP_RELAY_OPERATOR_KEY)");
}
const res = await fetch(`${base.replace(/\/$/, "")}/relay/tier-zaprite-order`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Recap-Operator-Key": operatorKey },
body: JSON.stringify({ user_id: userId, tier, return_url: returnUrl || undefined }),
signal: AbortSignal.timeout(timeoutMs),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data?.error || `relay tier-zaprite-order ${res.status}`);
err.status = res.status;
throw err;
}
return data;
}
// Read the buyable subscription plans + sats prices from the relay (the
// pricing source of truth). Operator-key authed. Returns
// { period_days, plans: [{tier, sats}] } or null when the relay is
// unreachable / unconfigured (caller falls back to a sane default).
export async function getRelayTierPlans({ timeoutMs = 8000 } = {}) {
const base = getRelayBaseURL();
const operatorKey = getRelayOperatorKey();
if (!base || !operatorKey) return null;
try {
const res = await fetch(`${base.replace(/\/$/, "")}/relay/tier-plans`, {
headers: { "X-Recap-Operator-Key": operatorKey },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
// List cloud users whose prepaid Pro/Max period expires within
// `withinDays` (future) or lapsed within the last `lapsedDays`. Operator-
// key authed. The relay owns subscription expiry; Recaps calls this to
// decide who to email expiry reminders to. Returns the parsed
// { subscriptions: [{user_id, tier, expires_at, expired, days_left}] }
// or null when the relay is unreachable / unconfigured.
export async function getRelayExpiringSubscriptions({
withinDays = 7,
lapsedDays = 3,
timeoutMs = 10000,
} = {}) {
const base = getRelayBaseURL();
const operatorKey = getRelayOperatorKey();
if (!base || !operatorKey) return null;
try {
const url = new URL(`${base.replace(/\/$/, "")}/relay/expiring-subscriptions`);
url.searchParams.set("within_days", String(withinDays));
url.searchParams.set("lapsed_days", String(lapsedDays));
const res = await fetch(url, {
headers: { "X-Recap-Operator-Key": operatorKey },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
// Streams a file off disk into a Blob with the given MIME type for
// FormData upload. Node's global Blob/File don't accept a stream
// directly the way browser File objects do, so we read into a Buffer