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:
+56
-13
@@ -24,22 +24,27 @@ import { calcCost } from "../gemini-helpers.js";
|
||||
|
||||
// Models exposed to the analysis fallback chain. Order matters — first
|
||||
// is the preferred default, the rest are tried in order if it fails.
|
||||
// The five Gemini models we expose. Verified valid against
|
||||
// ai.google.dev/gemini-api/docs/models — older IDs (gemini-3-pro-preview
|
||||
// shut down 2026-03-09, gemini-2.0-flash deprecated, gemini-3.1-flash*
|
||||
// never existed) are intentionally not in either list.
|
||||
export const GEMINI_ANALYSIS_MODELS = [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3.1-flash-lite",
|
||||
];
|
||||
|
||||
// Transcription models, in fallback order. Flash is best speed/cost
|
||||
// for audio → text; 2.5 Flash is the stable previous-gen multimodal
|
||||
// model and works well as a fallback when Gemini 3 Flash returns 503
|
||||
// (capacity / overload). The orchestration layer in server/index.js
|
||||
// iterates this list, retrying with the next model when one fails.
|
||||
// Transcription fallback order: Flash first (Flash is Google's
|
||||
// natural audio fit), Pro only as last-resort because Pro on audio
|
||||
// is significantly more expensive than Flash.
|
||||
export const GEMINI_TRANSCRIPTION_MODELS = [
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3.1-pro-preview",
|
||||
];
|
||||
|
||||
// Empty-response retries: when the SDK returns 200 with no text (which
|
||||
@@ -178,9 +183,15 @@ export function createGeminiProvider({ apiKey, timeoutMs = 900_000 } = {}) {
|
||||
chapters: chaptersHint,
|
||||
});
|
||||
|
||||
// thinkingLevel: "minimal" is only valid for Flash. Pro models
|
||||
// reject it. Match prior behavior precisely.
|
||||
const txConfig = model.includes("flash")
|
||||
// thinkingLevel is a Gemini 3.x param — Gemini 2.5 models use
|
||||
// a different shape (`thinkingBudget`, integer) and 400 on
|
||||
// `thinkingLevel`. Pro models reject thinking config entirely
|
||||
// for the transcribe pipeline. Only send for Gemini 3.x flash
|
||||
// variants where it's a valid latency/cost knob.
|
||||
const isGemini3Flash =
|
||||
model.includes("flash") &&
|
||||
(model.startsWith("gemini-3-") || model.startsWith("gemini-3.") || model.startsWith("gemini-3."));
|
||||
const txConfig = isGemini3Flash
|
||||
? { thinkingConfig: { thinkingLevel: "minimal" } }
|
||||
: {};
|
||||
|
||||
@@ -199,6 +210,20 @@ export function createGeminiProvider({ apiKey, timeoutMs = 900_000 } = {}) {
|
||||
config: {
|
||||
...txConfig,
|
||||
safetySettings: TRANSCRIPTION_SAFETY,
|
||||
// Transcripts of long audio are output-token-bound.
|
||||
// Gemini's default is small (commonly 8192) which is
|
||||
// enough for ~10-15 min of dense speech but truncates
|
||||
// 30-45 min chunks mid-transcript with no warning.
|
||||
// Observed (May 2026): a 45-min chunk transcribed by
|
||||
// gemini-3.1-flash-lite ended at local 31:05, losing
|
||||
// 14 minutes of speech silently; another chunk lost
|
||||
// 43 of 45 minutes after the model output 5 segments
|
||||
// and stopped. Setting this high gives the model room
|
||||
// to emit the full transcript; models that don't
|
||||
// support values this large will clamp internally to
|
||||
// their max. 65,536 is the upper bound for Gemini 3.x
|
||||
// flash variants per Google's docs.
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contents: [
|
||||
{
|
||||
@@ -272,8 +297,13 @@ export function createGeminiProvider({ apiKey, timeoutMs = 900_000 } = {}) {
|
||||
// UX: fail fast on a single model and let the outer fallback
|
||||
// chain in server/index.js walk to the next model (Pro → Pro
|
||||
// older → Flash → Flash 2.5) immediately. Caller can override
|
||||
// with retries: 2 if they want the old behavior.
|
||||
retries = 1,
|
||||
// Bumped 1 → 2 in 0.2.76 alongside the responseMimeType:json
|
||||
// change. Analyze is by far the cheapest pipeline phase
|
||||
// (~few seconds per call), so a third total attempt (1 initial
|
||||
// + 2 retries on caught error) is essentially free in wall time
|
||||
// but materially reduces "lost window" failures on transient
|
||||
// 503/429 blips. Callers can override.
|
||||
retries = 2,
|
||||
signal,
|
||||
}) {
|
||||
const result = await retryGemini(
|
||||
@@ -281,6 +311,19 @@ export function createGeminiProvider({ apiKey, timeoutMs = 900_000 } = {}) {
|
||||
withAbort(
|
||||
aiAnalyze.models.generateContent({
|
||||
model,
|
||||
config: {
|
||||
// JSON mode — Gemini guarantees the response body is
|
||||
// valid JSON when this is set. Eliminates the entire
|
||||
// class of "invalid JSON in window response" failures
|
||||
// that came from the model occasionally wrapping its
|
||||
// sections array in a prose preamble, a ```json```
|
||||
// markdown fence, or truncating the closing brace.
|
||||
// The prompt already asks for JSON; this turns that
|
||||
// into a hard server-enforced constraint on the
|
||||
// model\'s decoder. Mirrors recap-relay 0.2.69\'s
|
||||
// change for the relay-mode analyze path.
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
@@ -356,7 +399,7 @@ Format each line as:
|
||||
Rules:
|
||||
- Transcribe EVERY word spoken, do not skip or summarize anything.
|
||||
- Use [MM:SS] or [H:MM:SS] timestamp format at the start of each line.
|
||||
- Start a new timestamped line every 15-30 seconds or at natural speech pauses.
|
||||
- Start a new timestamped line every 15-30 seconds or at natural speech pauses or speaker changes.
|
||||
- Include filler words (um, uh, you know) for accuracy.
|
||||
- Speaker identification: FIRST consult the metadata above — descriptions and chapter titles usually name the host(s) and guest(s) explicitly, and the channel name is often the host's name. Match those names to the voices in the audio (introductions, "I'm Dax", "this is Will", first-person references) and use them as speaker labels. Format as: [MM:SS] Name: text. Only fall back to "Host"/"Guest" if no names appear in the metadata AND nobody is introduced by name in the audio.
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
+821
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user