Files
recap/server/license-middleware.js
T
Keysat 373d10595b Pluggable AI providers, relay credit system, picker UX overhaul
Captures roughly forty version bumps (v0.2.6 → v0.2.47) of work that
accumulated without commits.

- Pluggable provider system under server/providers/: gemini, anthropic,
  openai, openai-compatible, ollama, whisper-compatible, relay. Mix and
  match transcription + analysis per request via the picker UI.
- Relay backend integration. Hardcoded relay URL in server/relay-default.js
  (operator-controlled at build time, not user-configurable). New
  /api/relay/{status,policy} endpoints proxy to the relay; balance pings
  populate a cached credit display.
- Per-install identity in server/install-id.js for relay credit accounting.
  Sent to the relay as X-Recap-Install-Id; persists across upgrades, lost
  on a full uninstall + reinstall. Not surfaced in the UI.
- Admin login gate (server/admin-auth.js + setAdminPassword action). Scrypt
  password hash + HMAC-signed session cookie.
- Entitlement scheme rename: pro / max (each paired with subscriptions and
  relay_pro / relay_max), replacing the misleading "core" entitlement
  that conflicted with the user-facing "Core" tier name.
- Activation screen: dynamic credit count pulled from /api/relay/policy,
  "Skip — use free mode" button, accurate paid-feature list.
- Top toolbar: inline credit-balance pill (or "BYO configured" fallback),
  Upgrade + "I have a key" buttons.
- Picker UI: per-provider sections with Save/Test/Delete buttons, sections
  collapsible by chevron, default-collapsed unless currently selected,
  "Use comped credits (reset to relay)" link when the user has strayed,
  green hint under inputs whose values are server-configured.
- Activity log: chevron-collapsible groups per video, refresh-survival via
  localStorage + a 500-entry server-side buffer, explicit Clear button.
- YouTube captions fast-path with user toggle (skips audio download + AI
  transcription when captions are available — uncheck for speaker labels).
- Cancel button: AbortController plumbed through every provider SDK call;
  retryAPI short-circuits on AbortError; cancellation events surface in
  the activity log instead of silent retries.
- Long-video analysis: auto-coalesce transcript entries before building the
  analysis prompt so local-model context windows (32k-ish) don't overflow.
  Original entries preserved for transcript display via an index map; the
  analyzer sees a coarser view but click-to-seek timestamps stay precise.
- StartOS action grouping (Setup / AI Providers) so the actions list is
  navigable.
- Manifest description rewritten to reflect multi-provider support and
  free-tier relay credits.
- Smaller fixes: summarize-button enablement no longer requires a Gemini
  key when other providers are configured; analysis fallback chain handles
  context-length and 503 capacity errors; single-segment expansion for
  providers that don't return per-segment timestamps (Parakeet et al.);
  many other UX polish items.
2026-05-11 23:46:20 -05:00

385 lines
16 KiB
JavaScript

// License gate, Pro-tier feature gates, license routes, and the free-
// tier concurrency lock. All license-aware request-handling lives here.
//
// LIC is exported as a `let` binding (ESM live binding) — importers
// reading it get the current value. The activate / deactivate routes
// and refreshLicenseOnline mutate it inside the module.
import * as license from "./license.js";
// ── Module state ────────────────────────────────────────────────────────────
// LIC is the in-memory snapshot of the current license state, refreshed
// on startup, periodically against the licensing server, and after each
// activate / deactivate.
export let LIC = license.checkLicense();
console.log(
`[license] state=${LIC.state} entitlements=[${[...LIC.entitlements].join(",")}]` +
(LIC.reason ? ` reason=${LIC.reason}` : "")
);
// Free-tier concurrency lock. Unlicensed users may process one video at
// a time — second submission while another is in flight returns 409 from
// /api/process with details about what's running. The /api/process
// handler calls tryAcquireFreeSlot() at entry and releaseFreeSlot() in
// its finally block.
//
// The current-job object also drives:
// - /api/process/current — UI status banner after a browser refresh
// - /api/process/cancel — sets `aborted: true` AND fires the request's
// AbortController so in-flight model API calls are interrupted
// immediately (not just at the next pipeline checkpoint).
let currentFreeJob = null; // { url, title, startedAt, aborted, abortController } | null
// ── Online validation tunables ──────────────────────────────────────────────
// 30 min default scheduled cycle catches revocations / suspensions /
// expirations within at most half an hour. Bounded so a key revoked on
// Keysat doesn't sit unnoticed for hours on the customer's machine.
const VALIDATE_INTERVAL_MS = parseInt(
process.env.RECAP_VALIDATE_INTERVAL_MS || String(30 * 60 * 1000),
10
);
const ACTIVATE_VALIDATE_TIMEOUT_MS = 8000;
// 5 s file poll is the fast path for keys set via the StartOS "Set Recap
// License" action — the cost is one stat call per file every 5 s, which
// is negligible.
const LICENSE_FILE_POLL_MS = parseInt(
process.env.RECAP_LICENSE_FILE_POLL_MS || "5000",
10
);
// Opportunistic refresh: when /api/license-status is hit and the cached
// LIC was last validated more than this long ago, fire validateOnline in
// the background. The web UI hits license-status on every page load, so
// revocations get caught the next time anyone opens the app — usually
// well under the scheduled 30 min tick.
const OPPORTUNISTIC_REFRESH_THRESHOLD_MS = parseInt(
process.env.RECAP_OPPORTUNISTIC_REFRESH_MS || String(10 * 60 * 1000),
10
);
// ── Online refresh ──────────────────────────────────────────────────────────
// Calls the licensing server (with the network-error grace logic in
// license.validateOnline) and updates LIC. Logs only on state/reason
// changes to keep a clean log on healthy machines.
export async function refreshLicenseOnline(reason) {
const prev = LIC;
try {
LIC = await license.validateOnline();
} catch (e) {
console.error(`[license] refresh threw (${reason}):`, e?.message || e);
return;
}
if (LIC.state !== prev.state || LIC.reason !== prev.reason) {
console.log(
`[license] refresh (${reason}): state=${LIC.state}` +
(LIC.reason ? ` reason=${LIC.reason}` : "") +
` entitlements=[${[...LIC.entitlements].join(",")}]`
);
}
}
// Kick off a startup refresh and the periodic poll. Async startup so the
// server doesn't block on a slow Keysat round-trip.
export function startLicenseRefresh() {
refreshLicenseOnline("startup").catch(() => {});
setInterval(() => {
refreshLicenseOnline("scheduled").catch(() => {});
}, VALIDATE_INTERVAL_MS);
// Faster offline-only re-read so a license set via the "Set Recap
// License" StartOS action (or a manual edit to license.txt) is picked
// up within seconds instead of 6 h. Calls checkLicense() rather than
// validateOnline() to avoid hammering Keysat — the next scheduled
// validateOnline tick will confirm with the server. If a fresh key
// appears, kick an immediate online check too so an unrevoked Pro
// license doesn't get stuck pending until the 6 h tick.
setInterval(() => {
const prev = LIC;
const next = license.checkLicense();
if (next.licenseId !== prev.licenseId || next.state !== prev.state) {
LIC = next;
console.log(
`[license] file refresh: state=${LIC.state}` +
(LIC.reason ? ` reason=${LIC.reason}` : "") +
` entitlements=[${[...LIC.entitlements].join(",")}]`
);
if (next.state === "licensed") {
refreshLicenseOnline("file change").catch(() => {});
}
}
}, LICENSE_FILE_POLL_MS);
}
// ── Free-tier slot management ───────────────────────────────────────────────
// Whether the current LIC counts as a paid user — i.e. holds either the
// `pro` or `max` entitlement. Keysat policy cards mint Pro licenses with
// `pro` and Max licenses with `max`; both unlock the same Recap-side
// gates today (subscriptions, no free-tier concurrency lock), with the
// relay layer responsible for the Pro-vs-Max quota split.
export function isPaidUser() {
if (LIC.state !== "licensed") return false;
return LIC.entitlements.has("pro") || LIC.entitlements.has("max");
}
// Inverse of isPaidUser — kept as a separate export because that's how
// most callers phrase the check ("if free, apply rate limits / show
// upgrade banner / etc.").
export function isFreeUser() {
return !isPaidUser();
}
// Returns true if the slot was acquired, false if another free job is in
// flight. The /api/process handler must release via releaseFreeSlot()
// in a finally block on every exit path.
//
// `abortController` is the request's AbortController — abortCurrentFreeJob
// calls .abort() on it so in-flight provider SDK calls are interrupted at
// the network layer, not just at the next pipeline checkpoint.
//
// `logs` is a server-side buffer the pipeline appends to (via
// appendCurrentJobLog) as each progress message is sent over SSE. After
// a browser refresh the client re-fetches /api/process/current and uses
// these to repopulate the activity log — without it, a refresh during
// a long pipeline silently drops everything the user has already seen.
export function tryAcquireFreeSlot({ url = "", title = "", abortController = null } = {}) {
if (currentFreeJob) return false;
currentFreeJob = {
url,
title,
startedAt: Date.now(),
aborted: false,
abortController,
logs: [],
};
return true;
}
// Push one entry onto the in-flight job's log buffer. No-op if there's
// no current job (e.g. licensed user — no free-tier tracking). Kept
// bounded so a multi-hour run doesn't grow the buffer without limit.
const MAX_LIVE_LOG_ENTRIES = 500;
export function appendCurrentJobLog(entry) {
if (!currentFreeJob || !entry) return;
currentFreeJob.logs.push(entry);
if (currentFreeJob.logs.length > MAX_LIVE_LOG_ENTRIES) {
currentFreeJob.logs.splice(0, currentFreeJob.logs.length - MAX_LIVE_LOG_ENTRIES);
}
}
export function releaseFreeSlot() {
currentFreeJob = null;
}
// Returns a JSON-friendly snapshot of the in-flight free job, or null.
// `includeLogs` is opt-in because the typical poll (banner refresh) only
// cares about the small header fields — logs are only needed when the
// client is rehydrating after a browser refresh.
export function getCurrentFreeJob({ includeLogs = false } = {}) {
if (!currentFreeJob) return null;
const out = {
url: currentFreeJob.url,
title: currentFreeJob.title,
startedAt: currentFreeJob.startedAt,
elapsedMs: Date.now() - currentFreeJob.startedAt,
aborted: currentFreeJob.aborted,
};
if (includeLogs) out.logs = [...currentFreeJob.logs];
return out;
}
// Mark the current job as cancelled AND fire its AbortController so any
// in-flight provider SDK call rejects immediately. Pipeline code also
// polls isFreeJobAborted() at major checkpoints — that handles the gaps
// between awaitable calls (e.g. while looping over yt-dlp retry delays).
// The handler's finally block runs releaseFreeSlot(), so we don't clear
// currentFreeJob here — that avoids a race where a follow-up /api/process
// request acquires the slot while the cancelled call is still cleaning up.
// Returns true if there was a job to cancel.
export function abortCurrentFreeJob() {
if (!currentFreeJob) return false;
currentFreeJob.aborted = true;
try {
currentFreeJob.abortController?.abort();
} catch {}
return true;
}
export function isFreeJobAborted() {
return !!(currentFreeJob && currentFreeJob.aborted);
}
// ── Endpoints reachable without a license ───────────────────────────────────
// /api/process is open so unlicensed (free-tier) users can summarize one
// video at a time with their own Gemini key. The route handler enforces
// BYO-key + the concurrency lock for free users.
const LICENSE_OPEN_PATHS = new Set([
"/api/health",
"/api/heartbeat",
"/api/status",
"/api/license-status",
"/api/license/activate",
"/api/license/deactivate",
// Install identity — needed by the relay client before any license
// exists, and by the UI's settings panel for verification.
"/api/install-id",
// Relay balance display — the UI needs to render credit counts even
// for unlicensed (Core) users since they get free lifetime credits.
"/api/relay/status",
// Tier-policy lookup powers dynamic copy on the activation screen
// (e.g. "N relay credits" pulled live from the relay). Unlicensed
// users see the activation screen, so this must be open to them.
"/api/relay/policy",
]);
// Prefix-based open list: any /api/* path that startsWith one of these
// is reachable without a license. Library + saved summaries are part of
// the free experience (the app would feel broken without them — you'd
// summarize a video and never be able to find it again). Subscriptions,
// clips, and the relay remain paid. /api/providers/* is open so any
// user (including unlicensed) can test connectivity to their LLM
// providers before deciding whether to buy. /api/process is a prefix
// (not an exact-match in LICENSE_OPEN_PATHS) because /api/process,
// /api/process/current, and /api/process/cancel all need to be reachable
// for the free-tier flow — without /current the in-flight banner can't
// clear after the pipeline finishes, and without /cancel the Cancel
// button silently fails for unlicensed users.
const LICENSE_OPEN_PREFIXES = [
"/api/history",
"/api/library",
"/api/providers",
"/api/process",
];
// ── Pro-tier feature gates ──────────────────────────────────────────────────
// Each entry maps URL prefixes → required entitlement; first match wins.
// A licensed user without the right entitlement gets a clean 402
// feature_not_in_tier (vs. the generic activation gate above).
//
// History + library used to be gated here. They moved to the free tier
// (see LICENSE_OPEN_PREFIXES above) — without saved summaries the app
// feels broken on first use, and the real paid value is auto-queue +
// relay credits.
const PRO_FEATURE_GATES = [
{
prefixes: ["/api/subscriptions", "/api/auto-queue", "/api/sub-check-log"],
entitlement: "subscriptions",
feature: "subscriptions",
message:
"Channel subscriptions and auto-queue require a paid license. Upgrade to unlock.",
},
];
// ── Middleware setup ────────────────────────────────────────────────────────
// Registers the activation gate + Pro-tier gates on the given Express
// app. Order matters — both must be in the chain BEFORE any /api/*
// route registration, so call this early in boot.
export function setupLicenseMiddleware(app) {
// Activation-screen gate: any /api/* request without a valid license is
// rejected with 402, except the allowlist above. Non-/api requests
// (the static frontend, /assets, etc.) pass through so the UI can load.
app.use((req, res, next) => {
if (!req.path.startsWith("/api/")) return next();
if (LICENSE_OPEN_PATHS.has(req.path)) return next();
if (LICENSE_OPEN_PREFIXES.some((p) => req.path.startsWith(p))) return next();
if (isPaidUser()) return next();
return res.status(402).json({
error: "license_required",
message:
LIC.state === "licensed"
? "Your license is missing the 'pro' or 'max' entitlement. Contact the seller."
: "This feature requires a Recap license. Upgrade to unlock.",
state: LIC.state,
reason: LIC.reason,
activate_url: "/#activate",
keysat_base_url: license.KEYSAT_BASE_URL,
product_slug: license.PRODUCT_SLUG,
});
});
// Pro-tier feature gates run after the activation gate.
app.use((req, res, next) => {
for (const gate of PRO_FEATURE_GATES) {
if (gate.prefixes.some((p) => req.path.startsWith(p))) {
if (LIC.entitlements.has(gate.entitlement)) return next();
return res.status(402).json({
error: "feature_not_in_tier",
feature: gate.feature,
message: gate.message,
keysat_base_url: license.KEYSAT_BASE_URL,
product_slug: license.PRODUCT_SLUG,
});
}
}
next();
});
}
// ── License management endpoints ────────────────────────────────────────────
// Open by virtue of being in LICENSE_OPEN_PATHS — the gate lets them
// through unauthenticated.
export function setupLicenseRoutes(app) {
app.get("/api/license-status", (_req, res) => {
// Opportunistic refresh: if the cached state is more than
// OPPORTUNISTIC_REFRESH_THRESHOLD_MS old, fire a validateOnline in
// the background. Doesn't block the response — the next status hit
// (or the next browser refresh) sees the updated state. Caps the
// worst-case revocation-detection latency for active users at the
// threshold value (default 10 min).
if (LIC.state === "licensed") {
const lastValidated = LIC.lastValidatedAt
? new Date(LIC.lastValidatedAt).getTime()
: 0;
const ageMs = Date.now() - lastValidated;
if (ageMs > OPPORTUNISTIC_REFRESH_THRESHOLD_MS) {
refreshLicenseOnline("opportunistic").catch(() => {});
}
}
res.json(license.publicView(LIC));
});
app.post("/api/license/activate", async (req, res) => {
try {
LIC = license.activate(req.body && req.body.license_key);
} catch (e) {
if (e && e.code === "bad_format") {
return res.status(400).json({
error: "bad_format",
message: "Expected a license key starting with 'LIC1-'.",
});
}
return res.status(500).json({ error: "activation_failed", message: e?.message });
}
if (LIC.state !== "licensed") {
// Offline signature check failed — no point hitting the server.
return res.status(400).json({
ok: false,
error: LIC.reason || "invalid",
...license.publicView(LIC),
});
}
// Offline check passed. Confirm with the licensing server so a key
// that was revoked before activation gets rejected immediately. Cap
// the wait so a slow server doesn't hang the activation UI — if we
// time out, accept the offline-verified state and let the periodic
// poll catch up.
await Promise.race([
refreshLicenseOnline("activation"),
new Promise((resolve) => setTimeout(resolve, ACTIVATE_VALIDATE_TIMEOUT_MS)),
]);
if (LIC.state === "licensed") {
return res.json({ ok: true, ...license.publicView(LIC) });
}
return res.status(400).json({
ok: false,
error: LIC.reason || "invalid",
...license.publicView(LIC),
});
});
app.post("/api/license/deactivate", (_req, res) => {
LIC = license.deactivate();
res.json({ ok: true, ...license.publicView(LIC) });
});
}