0ae59f3550
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
465 lines
21 KiB
JavaScript
465 lines
21 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",
|
|
// Audio-first ("walking mode") TTS. The /api/tts routes self-gate
|
|
// access (Max entitlement in multi mode; operator-only otherwise), so
|
|
// the blanket license middleware must let them through to that gate
|
|
// rather than 402-ing single-mode operators or Max users here.
|
|
"/api/tts",
|
|
// In-app purchase flow: GET /api/license/policies, POST
|
|
// /api/license/purchase, GET /api/license/poll/<invoiceId>. Buyers
|
|
// are unlicensed by definition — they must reach these before any
|
|
// license exists.
|
|
"/api/license/policies",
|
|
"/api/license/purchase",
|
|
"/api/license/poll",
|
|
// Relay credit top-up purchases: GET /api/credits/packages, POST
|
|
// /api/credits/buy, GET /api/credits/invoice/<id>. Buying credits
|
|
// doesn't require a license — Core (free) users should be able to
|
|
// top up just as easily as Pro/Max. The relay itself enforces
|
|
// billing via BTCPay; we just proxy.
|
|
"/api/credits",
|
|
// Self-serve subscription purchase: POST /api/billing/buy, GET
|
|
// /api/billing/status. A Core (free) user buying their way UP to
|
|
// Pro/Max is unlicensed by definition, so the activation gate must
|
|
// let them reach the buy + poll routes. The routes self-gate to a
|
|
// real signed-in user (req.user.id).
|
|
"/api/billing",
|
|
];
|
|
|
|
// ── 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 Pro or Max plan. 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 Recaps 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))) {
|
|
// Multi mode (cloud): per-tenant — the user's relay-owned tier
|
|
// decides. Pro/Max (or the admin/operator) get in; free tenants get
|
|
// a clear 402. This is the per-tenant subscriptions gate.
|
|
if (req.recapMode === "multi") {
|
|
const tier = req.user?.tier;
|
|
if (
|
|
(req.user && req.user.is_admin) ||
|
|
tier === "pro" ||
|
|
tier === "max"
|
|
) {
|
|
return next();
|
|
}
|
|
return res.status(402).json({
|
|
error: "feature_not_in_tier",
|
|
feature: gate.feature,
|
|
message: gate.message,
|
|
});
|
|
}
|
|
// Single mode: the operator's own license carries the entitlement.
|
|
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) => {
|
|
// ── Multi-mode: return per-user view ────────────────────────────────
|
|
// The OPERATOR's license at /data/license.txt is "the install" — the
|
|
// pool that pays for free + trial users. Each signed-in cloud user
|
|
// has their own state:
|
|
// - paid user (users.tier = pro|max) → synthesize a license view
|
|
// from their relay-owned tier (core-decoupling: no Keysat license)
|
|
// - free tenant (signed in, Core tier) → unlicensed view (they're
|
|
// not Pro; their balance comes from tenant_credits via /relay/status)
|
|
// - anonymous/trial → unlicensed view (the badge should show trial
|
|
// credits, NOT the operator's PRO tier)
|
|
// - admin (is_admin = 1) → the operator's LIC (they ARE the
|
|
// operator; same UX as single mode)
|
|
if (req.recapMode === "multi") {
|
|
if (req.user && req.user.is_admin) {
|
|
// Operator viewing their own server: full operator license view.
|
|
return res.json(license.publicView(LIC));
|
|
}
|
|
// Paid cloud user — the tier is the relay-owned subscription tier,
|
|
// cached on the Recaps account (req.user.tier) and kept in sync by
|
|
// the operator grant flow. Synthesize a license view from it so the
|
|
// badge + per-user gates match a license-bearing user. Core-
|
|
// decoupling: this is the SOLE source of paid status in multi mode —
|
|
// a leftover per-user keysat_license is deliberately NOT consulted
|
|
// (licenses are moot in the cloud path), so the badge always agrees
|
|
// with the relay-owned tier shown in the operator's Tenants panel.
|
|
const tier = req.user?.tier;
|
|
if (req.user && (tier === "pro" || tier === "max")) {
|
|
return res.json(license.viewForTier(tier));
|
|
}
|
|
// Free tenant (tier core), trial, or fully anonymous — return an
|
|
// unlicensed view. Frontend uses this to hide the PRO badge / "manage
|
|
// license" affordances. Balance display comes from /api/relay/status
|
|
// (which is also multi-mode-aware).
|
|
return res.json(license.publicView(license.parseLicenseKey("")));
|
|
}
|
|
|
|
// ── Single-mode (the existing path) ─────────────────────────────────
|
|
// 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) });
|
|
});
|
|
}
|