Files
recap/server/license.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

408 lines
15 KiB
JavaScript

// ── Keysat license verification ──────────────────────────────────────────
//
// Reads a LIC1-... key from disk (or env), verifies its Ed25519 signature
// against the operator's embedded public key, and exposes the resulting
// state + entitlement set to the rest of the server.
//
// Operator config — keep these three constants in sync with what's set in
// the Keysat admin UI:
// ISSUER_PEM → assets/issuer.pub (committed; non-secret)
// PRODUCT_SLUG → must match the product slug created in Keysat
// KEYSAT_BASE_URL → optional, only used by online validate() / purchase
//
// Tier model for this app:
// Core / Free — no license. Library + history + lifetime relay
// credits (count set by recap-relay's Adjust Tier
// Quotas action; default 10). BYO API keys or
// self-hosted model URL gives unlimited use.
// Pro — license with entitlements:
// "pro" — flags the license as paid; unlocks the
// activation gate
// "subscriptions" — channel + podcast subs, auto-queue, sub-check log
// "relay_pro" — recap-relay reads this and applies the Pro
// monthly cap (defaults: 50/mo, 25 Gemini-served)
// Max — license with entitlements:
// "max" — same gate as "pro" (server treats either as paid)
// "subscriptions" — same feature set as Pro
// "relay_max" — recap-relay applies the Max monthly cap (default
// unlimited, with 50/month Gemini sub-cap)
//
// Older entitlements ("core", "library", "history", "clips") were used
// in pre-1.0 builds. They are unused by the current server; harmless to
// ship in legacy keys.
import fs from "fs";
import path from "path";
import { Verifier, PublicKey, Client } from "@keysat/licensing-client";
export const PRODUCT_SLUG = "recap";
export const KEYSAT_BASE_URL = "https://licensing.keysat.xyz";
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const PEM_PATH = path.join(__dirname, "..", "assets", "issuer.pub");
const ISSUER_PEM = fs.readFileSync(PEM_PATH, "utf8");
// License file lives next to existing config/ and history/ in DATA_DIR.
// On StartOS that's /data; on local Mac dev it's the project root.
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "..");
export const LICENSE_PATH =
process.env.RECAP_LICENSE_KEY_PATH ||
path.join(DATA_DIR, "license.txt");
// StartOS config sidecar — the "Set Recap License" action writes the
// pasted key to this file's recap_license_key field. Read as a fallback
// after license.txt so the web-UI activation flow still wins if both
// are set.
const STARTOS_CONFIG_PATH = path.join(DATA_DIR, "config", "startos-config.json");
// Grace ceiling for network errors. As long as we successfully validated
// against Keysat within this window, we keep the license live even if
// subsequent online checks fail (Keysat down, customer offline, etc.).
// Past the ceiling, we lock out — otherwise a revoked key on a permanently
// offline machine would never get caught.
const MAX_OFFLINE_DAYS = parseInt(
process.env.RECAP_MAX_OFFLINE_DAYS || "7",
10
);
const MAX_OFFLINE_MS = MAX_OFFLINE_DAYS * 24 * 60 * 60 * 1000;
// Sidecar file that tracks last successful online validation. Lets the
// grace window survive restarts.
const STATE_PATH = LICENSE_PATH + ".state.json";
// ── Verifier instance (built once at module load) ─────────────────────────
let verifier = null;
let verifierError = null;
try {
verifier = new Verifier(PublicKey.fromPem(ISSUER_PEM));
} catch (e) {
verifierError = e?.message || String(e);
console.error(`[license] failed to parse embedded public key: ${verifierError}`);
}
// Lazy HTTP client for online validation against the licensing service.
let onlineClient = null;
function getOnlineClient() {
if (!onlineClient) onlineClient = new Client(KEYSAT_BASE_URL);
return onlineClient;
}
// ── Helpers ───────────────────────────────────────────────────────────────
// Resolution order:
// 1. RECAP_LICENSE_KEY env var (overrides everything; useful for tests)
// 2. license.txt at LICENSE_PATH (web-UI activation writes here)
// 3. recap_license_key in startos-config.json ("Set Recap License" action)
// Read-only accessor for the raw license key (LIC1-...). Returns null
// when no license is configured. Used by the relay provider when
// attaching the Authorization header so the relay can do its cached
// online check against keysat. Don't add this to publicView — it's
// server-side only and should never reach the browser.
export function getRawLicenseKey() {
return readLicenseString();
}
function readLicenseString() {
const fromEnv = (process.env.RECAP_LICENSE_KEY || "").trim();
if (fromEnv) return fromEnv;
try {
const s = fs.readFileSync(LICENSE_PATH, "utf8").trim();
if (s) return s;
} catch {}
try {
const cfg = JSON.parse(fs.readFileSync(STARTOS_CONFIG_PATH, "utf8"));
const k = (cfg.recap_license_key || "").trim();
return k || null;
} catch {}
return null;
}
function readPersistedState() {
try {
return JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
} catch {
return null;
}
}
function writePersistedState(obj) {
try {
const tmp = STATE_PATH + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 });
fs.renameSync(tmp, STATE_PATH);
} catch (e) {
console.error(`[license] failed to write state: ${e?.message || e}`);
}
}
function clearPersistedState() {
try {
fs.unlinkSync(STATE_PATH);
} catch {}
}
function emptyState(extra = {}) {
return {
state: "unlicensed",
reason: null,
licenseId: null,
entitlements: new Set(),
expiresAt: null,
isTrial: false,
lastValidatedAt: null,
serverStatus: null,
graceUntil: null,
...extra,
};
}
// Definitive server reasons that should immediately invalidate the license.
// rate_limited is intentionally NOT here — that's a transient server-side
// throttle, not a verdict on the key.
const HARD_REJECTIONS = new Set([
"bad_format",
"bad_signature",
"not_found",
"revoked",
"suspended",
"expired",
"product_mismatch",
"fingerprint_mismatch",
"too_many_machines",
"invalid_state",
]);
// ── Public API ────────────────────────────────────────────────────────────
//
// checkLicense() — read + verify; returns a frozen-ish state object.
// Callers can re-invoke after activation to refresh. This is offline-only:
// it verifies the Ed25519 signature and layers on any persisted online-
// validation state. The hard online check happens in validateOnline().
export function checkLicense() {
if (verifierError) {
return emptyState({ state: "invalid", reason: `bad embedded key: ${verifierError}` });
}
const raw = readLicenseString();
if (!raw) return emptyState();
let base;
try {
const ok = verifier.verify(raw);
const payload = ok.payload || {};
// Reject keys minted for a different product (same operator, different SKU).
if (payload.productSlug && payload.productSlug !== PRODUCT_SLUG) {
return emptyState({ state: "invalid", reason: "product_mismatch" });
}
base = emptyState({
state: "licensed",
// payload.licenseId is a Uint8Array (raw 16-byte UUID); the canonical
// string form is licenseUuid. The frontend treats licenseId as a
// string (calls .slice on it), so always send the string here.
licenseId: payload.licenseUuid || null,
entitlements: new Set(payload.entitlements || []),
expiresAt: payload.expiresAt ? new Date(payload.expiresAt * 1000) : null,
isTrial: !!(payload.flags & 1),
});
} catch (e) {
return emptyState({ state: "invalid", reason: e?.message || "verify_failed" });
}
// Layer on persisted online state. If a previous online check found this
// key revoked/suspended/etc., honor that until a successful re-check.
const persisted = readPersistedState();
if (persisted) {
if (persisted.lastValidatedAt) {
base.lastValidatedAt = new Date(persisted.lastValidatedAt);
}
if (persisted.serverStatus) base.serverStatus = persisted.serverStatus;
if (persisted.graceUntil) base.graceUntil = new Date(persisted.graceUntil);
if (persisted.lastResult && persisted.lastResult !== "ok") {
// The last conclusive online check rejected this key. Stay invalid
// until a successful re-check overwrites the sidecar.
return emptyState({
state: "invalid",
reason: persisted.lastResult,
licenseId: base.licenseId,
lastValidatedAt: base.lastValidatedAt,
serverStatus: base.serverStatus,
});
}
}
return base;
}
// activate(rawKey) — write a pasted key to disk, then re-check.
// Returns the new license state. Throws on bad input format only;
// signature failures surface as state: 'invalid' with a reason.
//
// Clears any persisted online-validation state — a new key gets a fresh
// online check, untainted by what an old key was last told.
export function activate(rawKey) {
const key = (rawKey || "").trim();
if (!key.startsWith("LIC1-")) {
const err = new Error("bad_format");
err.code = "bad_format";
throw err;
}
// Write atomically-ish: write to temp file then rename.
const tmp = LICENSE_PATH + ".tmp";
fs.mkdirSync(path.dirname(LICENSE_PATH), { recursive: true });
fs.writeFileSync(tmp, key + "\n", { mode: 0o600 });
fs.renameSync(tmp, LICENSE_PATH);
clearPersistedState();
return checkLicense();
}
// deactivate() — remove the on-disk license + persisted online state.
// Idempotent. Returns the new (empty) state.
export function deactivate() {
try {
fs.unlinkSync(LICENSE_PATH);
} catch {}
clearPersistedState();
return checkLicense();
}
// validateOnline() — call licensing.keysat.xyz/v1/validate, merge the
// response into a fresh state object, and persist last-validated info.
//
// Behavior:
// • Server says ok=true → state stays/becomes "licensed", entitlements
// and expiry refreshed from server.
// • Server says ok=false with a hard reason (revoked, suspended, expired,
// not_found, product_mismatch, fingerprint_mismatch, too_many_machines)
// → state becomes "invalid"; persisted so
// checkLicense() keeps rejecting after restart.
// • Server says rate_limited → treated as a transient error; state kept.
// • Network error / timeout → state kept up to MAX_OFFLINE_DAYS since the
// last successful validate. Past the ceiling,
// state becomes "invalid" with reason
// "validation_overdue".
//
// Always returns a state object — never throws.
export async function validateOnline() {
const local = checkLicense();
// No key, or local sig failure — no point asking the server.
if (local.state !== "licensed" && local.state !== "invalid") return local;
const raw = readLicenseString();
if (!raw) return local;
let resp;
try {
resp = await getOnlineClient().validate(raw, { productSlug: PRODUCT_SLUG });
} catch (e) {
return applyNetworkErrorGrace(local, e?.code || e?.message || "network_error");
}
const now = new Date();
if (resp.ok) {
const next = emptyState({
state: "licensed",
licenseId: resp.licenseId || local.licenseId,
entitlements: new Set(resp.entitlements || [...local.entitlements]),
expiresAt: resp.expiresAt ? new Date(resp.expiresAt) : local.expiresAt,
isTrial: resp.isTrial != null ? !!resp.isTrial : local.isTrial,
lastValidatedAt: now,
serverStatus: resp.status || "active",
graceUntil: resp.graceUntil ? new Date(resp.graceUntil) : null,
});
writePersistedState({
lastValidatedAt: now.toISOString(),
serverStatus: next.serverStatus,
lastResult: "ok",
graceUntil: next.graceUntil ? next.graceUntil.toISOString() : null,
});
return next;
}
const reason = resp.reason || "rejected";
if (reason === "rate_limited") {
// Transient. Don't change state.
return applyNetworkErrorGrace(local, "rate_limited");
}
if (HARD_REJECTIONS.has(reason)) {
console.warn(`[license] online validation rejected: ${reason}`);
writePersistedState({
lastValidatedAt: now.toISOString(),
serverStatus: resp.status || reason,
lastResult: reason,
});
return emptyState({
state: "invalid",
reason,
licenseId: local.licenseId,
lastValidatedAt: now,
serverStatus: resp.status || reason,
});
}
// Unknown reason — be conservative, treat as transient so we don't lock
// out paying users on an SDK/server version mismatch.
console.warn(`[license] online validation returned unknown reason: ${reason}`);
return applyNetworkErrorGrace(local, reason);
}
function applyNetworkErrorGrace(local, errorReason) {
const persisted = readPersistedState();
const lastValidatedAt = persisted?.lastValidatedAt
? new Date(persisted.lastValidatedAt)
: null;
if (!lastValidatedAt) {
// Never successfully validated online. Allow offline state but flag it
// — the periodic poller will keep trying.
console.warn(
`[license] online validation unavailable (${errorReason}); no prior successful check yet`
);
return { ...local, lastValidatedAt: null };
}
const ageMs = Date.now() - lastValidatedAt.getTime();
if (ageMs > MAX_OFFLINE_MS) {
const ageDays = (ageMs / 86400000).toFixed(1);
console.warn(
`[license] online validation overdue: ${ageDays}d since last successful check (max ${MAX_OFFLINE_DAYS}d). Locking out.`
);
return emptyState({
state: "invalid",
reason: "validation_overdue",
licenseId: local.licenseId,
lastValidatedAt,
serverStatus: persisted?.serverStatus || null,
});
}
console.warn(
`[license] online validation skipped (${errorReason}); within ${MAX_OFFLINE_DAYS}d grace`
);
return { ...local, lastValidatedAt };
}
// publicView(state) — safe shape for /api/license-status responses.
// Never leaks the raw license key (it's a bearer credential).
export function publicView(state) {
return {
state: state.state,
reason: state.reason,
licenseId: state.licenseId,
entitlements: [...state.entitlements].sort(),
expiresAt: state.expiresAt ? state.expiresAt.toISOString() : null,
isTrial: !!state.isTrial,
productSlug: PRODUCT_SLUG,
keysatBaseUrl: KEYSAT_BASE_URL,
licensePath: LICENSE_PATH,
lastValidatedAt: state.lastValidatedAt
? state.lastValidatedAt.toISOString()
: null,
serverStatus: state.serverStatus || null,
graceUntil: state.graceUntil ? state.graceUntil.toISOString() : null,
};
}
// has(state, entitlement) — convenience wrapper for feature gates.
export function has(state, entitlement) {
return state && state.entitlements && state.entitlements.has(entitlement);
}