373d10595b
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.
301 lines
9.9 KiB
JavaScript
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 });
|
|
});
|
|
}
|