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

301 lines
9.9 KiB
JavaScript

// Admin login gate.
//
// Reads username + scrypt password hash + session secret out of
// /data/config/startos-config.json (set by the "Set Admin Password"
// StartOS action). When a hash is set, gates /api/* behind a signed
// HttpOnly cookie. The static frontend stays open so the login screen
// can paint, but every API endpoint except the gate's own + /api/health
// returns 401 admin_login_required until the cookie validates.
//
// Cookie format: <base64url(payload)>.<base64url(hmac)>
// payload: { u: username, iat: epoch_ms, fp: fingerprint(passwordHash) }
// hmac: HMAC-SHA256(payload, sessionSecret)
//
// Changing the password rotates fp, which invalidates all existing
// sessions on the next request. Changing/clearing the session secret
// has the same effect.
//
// ADMIN is exported as a `let` binding so importers see the live value
// after each config-poll refresh.
import fs from "fs/promises";
import path from "path";
import {
randomBytes,
scryptSync,
createHmac,
timingSafeEqual,
} from "crypto";
const COOKIE_NAME = "recap_admin_session";
const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const SCRYPT_KEYLEN = 64;
// Endpoints reachable WITHOUT an admin session, even when the gate is
// enabled. The login flow itself + the bare-minimum status endpoints.
const ADMIN_OPEN_PATHS = new Set([
"/api/admin/status",
"/api/admin/login",
"/api/admin/logout",
"/api/health",
]);
// ── Module state ────────────────────────────────────────────────────────────
// Live snapshot of the admin-auth config. Refreshed from
// /data/config/startos-config.json every CONFIG_POLL_MS. `enabled` is
// derived (true iff a hash is set).
export let ADMIN = {
enabled: false,
username: "",
passwordHash: "",
passwordSalt: "",
sessionSecret: "",
};
let startosConfigPath = null;
// ── Init ────────────────────────────────────────────────────────────────────
export async function initAdminAuth({ dataDir }) {
startosConfigPath = path.join(dataDir, "config", "startos-config.json");
await refreshAdminConfig("startup");
const pollMs = parseInt(
process.env.RECAP_CONFIG_POLL_MS || "3000",
10
);
setInterval(() => {
refreshAdminConfig("config poll").catch(() => {});
}, pollMs);
}
async function refreshAdminConfig(reason) {
let next = {
enabled: false,
username: "",
passwordHash: "",
passwordSalt: "",
sessionSecret: "",
};
try {
const content = await fs.readFile(startosConfigPath, "utf-8");
const cfg = JSON.parse(content);
next = {
enabled: !!(cfg.recap_admin_password_hash && cfg.recap_admin_password_salt),
username: cfg.recap_admin_username || "",
passwordHash: cfg.recap_admin_password_hash || "",
passwordSalt: cfg.recap_admin_password_salt || "",
sessionSecret: cfg.recap_admin_session_secret || "",
};
} catch {
// File missing / unreadable — leave gate disabled.
}
if (
next.enabled !== ADMIN.enabled ||
next.username !== ADMIN.username ||
next.passwordHash !== ADMIN.passwordHash ||
next.sessionSecret !== ADMIN.sessionSecret
) {
ADMIN = next;
if (reason !== "config poll" || ADMIN.enabled !== false) {
console.log(
`[admin-auth] refresh (${reason}): enabled=${ADMIN.enabled} user=${ADMIN.username || "(unset)"}`
);
}
} else {
ADMIN = next;
}
}
// ── Cookie helpers ──────────────────────────────────────────────────────────
function b64url(buf) {
return Buffer.from(buf)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function b64urlDecode(s) {
const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4);
const padded = s + "=".repeat(pad);
return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}
function hashFingerprint(hash) {
// First 16 hex chars of the password hash. Stored in the cookie so
// changing the password invalidates all existing sessions.
return (hash || "").slice(0, 16);
}
function signSession({ username, hash, secret }) {
const payload = JSON.stringify({
u: username,
iat: Date.now(),
fp: hashFingerprint(hash),
});
const payloadB64 = b64url(payload);
const sig = createHmac("sha256", secret).update(payloadB64).digest();
return `${payloadB64}.${b64url(sig)}`;
}
function verifySession(token, { username, hash, secret }) {
if (!token || typeof token !== "string") return false;
const dot = token.indexOf(".");
if (dot < 0) return false;
const payloadB64 = token.slice(0, dot);
const sigB64 = token.slice(dot + 1);
if (!payloadB64 || !sigB64) return false;
let expected;
try {
expected = createHmac("sha256", secret).update(payloadB64).digest();
} catch {
return false;
}
let provided;
try {
provided = b64urlDecode(sigB64);
} catch {
return false;
}
if (provided.length !== expected.length) return false;
if (!timingSafeEqual(provided, expected)) return false;
let payload;
try {
payload = JSON.parse(b64urlDecode(payloadB64).toString("utf-8"));
} catch {
return false;
}
if (!payload || payload.u !== username) return false;
if (payload.fp !== hashFingerprint(hash)) return false;
if (typeof payload.iat !== "number") return false;
if (Date.now() - payload.iat > COOKIE_MAX_AGE_MS) return false;
return true;
}
function parseCookies(header) {
const out = {};
if (!header || typeof header !== "string") return out;
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq < 0) continue;
const k = part.slice(0, eq).trim();
const v = part.slice(eq + 1).trim();
if (k) out[k] = decodeURIComponent(v);
}
return out;
}
function buildSetCookie(value, { req, maxAgeMs }) {
const parts = [
`${COOKIE_NAME}=${value}`,
"HttpOnly",
"SameSite=Lax",
"Path=/",
];
if (maxAgeMs > 0) {
parts.push(`Max-Age=${Math.floor(maxAgeMs / 1000)}`);
} else {
parts.push("Max-Age=0");
}
// Mark Secure only when the request itself is HTTPS, so the cookie
// still works on plain-HTTP LAN access (the common StartOS dev setup).
const proto = (req.headers["x-forwarded-proto"] || "").toString().toLowerCase();
const isHttps = req.secure || proto.includes("https");
if (isHttps) parts.push("Secure");
return parts.join("; ");
}
function isSessionAuthed(req) {
if (!ADMIN.enabled) return true;
const cookies = parseCookies(req.headers.cookie);
return verifySession(cookies[COOKIE_NAME], {
username: ADMIN.username,
hash: ADMIN.passwordHash,
secret: ADMIN.sessionSecret,
});
}
// ── Middleware ──────────────────────────────────────────────────────────────
// Register BEFORE setupLicenseMiddleware. When the admin gate is
// disabled, this is a no-op pass-through.
export function setupAdminAuthMiddleware(app) {
app.use((req, res, next) => {
if (!ADMIN.enabled) return next();
if (!req.path.startsWith("/api/")) return next();
if (ADMIN_OPEN_PATHS.has(req.path)) return next();
if (isSessionAuthed(req)) return next();
return res.status(401).json({
error: "admin_login_required",
message: "Admin login required.",
});
});
}
// ── Routes ──────────────────────────────────────────────────────────────────
export function setupAdminAuthRoutes(app) {
app.get("/api/admin/status", (req, res) => {
res.json({
enabled: ADMIN.enabled,
authed: isSessionAuthed(req),
username: ADMIN.enabled ? ADMIN.username : null,
});
});
app.post("/api/admin/login", (req, res) => {
if (!ADMIN.enabled) {
// No password set; treat as success so the frontend doesn't get
// stuck on the login screen if the admin clears the password.
return res.json({ ok: true, enabled: false });
}
const username = (req.body && req.body.username) || "";
const password = (req.body && req.body.password) || "";
if (!username || !password) {
return res
.status(400)
.json({ error: "missing_credentials", message: "Username and password required." });
}
if (username !== ADMIN.username) {
return res
.status(401)
.json({ error: "invalid_credentials", message: "Invalid username or password." });
}
let computed;
try {
computed = scryptSync(password, ADMIN.passwordSalt, SCRYPT_KEYLEN);
} catch {
return res
.status(500)
.json({ error: "hash_failed", message: "Could not verify password." });
}
let stored;
try {
stored = Buffer.from(ADMIN.passwordHash, "hex");
} catch {
return res
.status(500)
.json({ error: "stored_hash_invalid", message: "Stored password hash is unreadable." });
}
if (computed.length !== stored.length || !timingSafeEqual(computed, stored)) {
return res
.status(401)
.json({ error: "invalid_credentials", message: "Invalid username or password." });
}
const token = signSession({
username: ADMIN.username,
hash: ADMIN.passwordHash,
secret: ADMIN.sessionSecret,
});
res.setHeader(
"Set-Cookie",
buildSetCookie(token, { req, maxAgeMs: COOKIE_MAX_AGE_MS })
);
res.json({ ok: true, enabled: true, username: ADMIN.username });
});
app.post("/api/admin/logout", (req, res) => {
res.setHeader("Set-Cookie", buildSetCookie("", { req, maxAgeMs: 0 }));
res.json({ ok: true });
});
}