Save in-progress keysat integration and StartOS 0.4 work

Snapshot of the working tree before cleanup. Captures:
- Keysat licensing: server/license.js, /api/license/* endpoints in
  server/index.js, activation modal in public/index.html, embedded
  Ed25519 issuer key (assets/issuer.pub).
- StartOS 0.4 expansion: setApiKey action, version files v0.1.1
  through v0.1.15, file-models/config.json.ts, manifest updates.
- Self-hosted registry server (startos-registry/).
- Build/deploy scripts (bin/bump-version.sh, bin/deploy.sh, vendored
  yt-dlp binary), .gitignore, .deploy.env.example.
- Recent design docs (KEYSAT_INTEGRATION.md, UPGRADE-DESIGN.md) —
  retained here so they remain recoverable when removed in the
  follow-up cleanup commit.
This commit is contained in:
Keysat
2026-05-08 09:39:17 -05:00
parent 8298c083c7
commit 574a16d9fa
666 changed files with 71889 additions and 724 deletions
+285 -96
View File
@@ -9,6 +9,7 @@ import os from "os";
import https from "https";
import http from "http";
import { GoogleGenAI } from "@google/genai";
import * as license from "./license.js";
const execFileAsync = promisify(execFile);
const app = express();
@@ -73,11 +74,9 @@ function ytCookieArgs() {
return [];
}
// Extra yt-dlp args for robustness (browser impersonation, rate limiting)
// Extra yt-dlp args for robustness (rate limiting)
function ytExtraArgs() {
const args = [];
// Browser impersonation helps avoid TLS fingerprint detection on servers
args.push("--impersonate", "chrome");
// Sleep between requests to avoid rate limiting during batch operations
args.push("--sleep-interval", "1", "--max-sleep-interval", "3");
return args;
@@ -98,6 +97,125 @@ function resolveApiKey(clientKey) {
app.use(cors());
app.use(express.json({ limit: "100mb" }));
// ── Keysat licensing (hard-gate / activate-screen flavor) ─────────────────
// All /api/* routes return 402 until a valid license is activated, except
// the allowlisted endpoints that exist precisely so the frontend can render
// an activation UI. See server/license.js for the verifier.
let LIC = license.checkLicense();
console.log(
`[license] state=${LIC.state} entitlements=[${[...LIC.entitlements].join(",")}]` +
(LIC.reason ? ` reason=${LIC.reason}` : "")
);
// Endpoints reachable without a license — kept intentionally minimal.
const LICENSE_OPEN_PATHS = new Set([
"/api/health",
"/api/heartbeat",
"/api/status",
"/api/license-status",
"/api/license/activate",
"/api/license/deactivate",
]);
// 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 (LIC.state === "licensed" && LIC.entitlements.has("core")) return next();
return res.status(402).json({
error: "license_required",
message:
LIC.state === "licensed"
? "Your license is missing the 'core' entitlement. Contact the seller."
: "This service requires a Keysat license. Activate to continue.",
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. 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).
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 license. Upgrade to unlock.",
},
{
prefixes: ["/api/history"],
entitlement: "history",
feature: "history",
message:
"Summary history requires a Pro license. Upgrade to unlock.",
},
{
prefixes: ["/api/library"],
entitlement: "library",
feature: "library",
message:
"Library import/export requires a Pro license. Upgrade to unlock.",
},
];
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 — kept open by LICENSE_OPEN_PATHS above.
app.get("/api/license-status", (_req, res) => {
res.json(license.publicView(LIC));
});
app.post("/api/license/activate", (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") {
return res.json({ ok: true, ...license.publicView(LIC) });
}
return res.status(400).json({
ok: false,
error: "invalid",
...license.publicView(LIC),
});
});
app.post("/api/license/deactivate", async (_req, res) => {
try {
await fs.unlink(license.LICENSE_PATH).catch(() => {});
} catch {}
LIC = license.checkLicense();
res.json({ ok: true, ...license.publicView(LIC) });
});
// ── History storage ───────────────────────────────────────────────────────
async function saveToHistory(videoId, url, title, chunks, entries, logs, uploadDate, type) {
@@ -401,95 +519,11 @@ app.get("/api/cookies/status", async (req, res) => {
res.json(info);
});
// ── OAuth2 YouTube authentication (headless / StartOS) ────────────────────
// Uses yt-dlp's built-in OAuth2 device flow: user enters a code on any browser,
// token is cached in yt-dlp's cache dir (persisted on /data volume).
let oauthInProgress = false;
let oauthDeviceCode = null;
let oauthVerifyUrl = null;
// Check if OAuth token already exists in yt-dlp cache
app.get("/api/auth/oauth/status", async (req, res) => {
// yt-dlp stores OAuth tokens in its cache dir
const cacheDir = process.env.XDG_CACHE_HOME || path.join(DATA_DIR, "ytdlp-cache");
let hasToken = false;
try {
const files = await fs.readdir(path.join(cacheDir, "yt-dlp"), { recursive: true }).catch(() => []);
hasToken = files.some(f => f.includes("oauth") || f.includes("token"));
} catch {}
res.json({
hasToken,
hasCookies: ytCookiesFileExists,
cookieMethod: ytCookieMethod(),
inProgress: oauthInProgress,
deviceCode: oauthInProgress ? oauthDeviceCode : null,
verifyUrl: oauthInProgress ? oauthVerifyUrl : null,
});
});
// Initiate OAuth2 device code flow
app.post("/api/auth/oauth/start", async (req, res) => {
if (oauthInProgress) {
return res.json({ ok: true, message: "OAuth flow already in progress", deviceCode: oauthDeviceCode, verifyUrl: oauthVerifyUrl });
}
oauthInProgress = true;
oauthDeviceCode = null;
oauthVerifyUrl = null;
res.json({ ok: true, message: "OAuth flow started. Check /api/auth/oauth/status for the device code." });
// Run yt-dlp in background — it will output the device code to stderr
(async () => {
try {
const cacheDir = path.join(DATA_DIR, "ytdlp-cache");
await fs.mkdir(cacheDir, { recursive: true });
const proc = execFile("yt-dlp", [
"--username", "oauth",
"--password", "",
"--cache-dir", path.join(cacheDir, "yt-dlp"),
"--print", "%(title)s",
"--no-download",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
], { timeout: 300000, env: { ...process.env, XDG_CACHE_HOME: cacheDir } });
let stderrBuf = "";
proc.stderr?.on("data", (chunk) => {
stderrBuf += chunk.toString();
// Look for the device code pattern in yt-dlp output
const codeMatch = stderrBuf.match(/go to\s+(https?:\/\/\S+)\s+.*?enter.*?code[:\s]+([A-Z0-9-]+)/i);
if (codeMatch) {
oauthVerifyUrl = codeMatch[1];
oauthDeviceCode = codeMatch[2];
console.log(` 🔑 OAuth device code: ${oauthDeviceCode} — verify at ${oauthVerifyUrl}`);
}
});
await new Promise((resolve, reject) => {
proc.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`yt-dlp exited with code ${code}: ${stderrBuf.slice(-300)}`));
});
proc.on("error", reject);
});
console.log(" ✓ OAuth token cached successfully");
} catch (err) {
console.error(" ⚠ OAuth flow failed:", err.message);
} finally {
oauthInProgress = false;
}
})();
});
// ── History endpoints ─────────────────────────────────────────────────────
const metaPath = path.join(historyDir, "_meta.json");
// meta.json structure: { folders: [ { id, name, order, items: [sessionId, ...] } ], uncategorized: [sessionId, ...] }
// meta.json structure: { folders: [ { id, name, order, collapsed, items: [sessionId, ...] } ], uncategorized: [sessionId, ...] }
async function loadMeta() {
try {
return JSON.parse(await fs.readFile(metaPath, "utf-8"));
@@ -615,7 +649,7 @@ app.put("/api/history/meta", async (req, res) => {
app.post("/api/history/folders", async (req, res) => {
try {
const meta = await loadMeta();
const folder = { id: `folder-${Date.now()}`, name: req.body.name || "New Folder", items: [] };
const folder = { id: `folder-${Date.now()}`, name: req.body.name || "New Folder", collapsed: false, items: [] };
meta.folders.push(folder);
await saveMeta(meta);
res.json(folder);
@@ -638,6 +672,20 @@ app.put("/api/history/folders/:id", async (req, res) => {
}
});
// Update a folder's collapsed state
app.put("/api/history/folders/:id/collapsed", async (req, res) => {
try {
const meta = await loadMeta();
const folder = meta.folders.find(f => f.id === req.params.id);
if (!folder) return res.status(404).json({ error: "Folder not found" });
folder.collapsed = !!req.body.collapsed;
await saveMeta(meta);
res.json({ ok: true, collapsed: folder.collapsed });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Delete a folder (items move to uncategorized)
app.delete("/api/history/folders/:id", async (req, res) => {
try {
@@ -684,6 +732,119 @@ app.put("/api/history/move", async (req, res) => {
}
});
// ── Library export/import ────────────────────────────────────────────────
app.get("/api/library/export", async (req, res) => {
try {
const meta = await loadMeta();
const files = await fs.readdir(historyDir);
const sessions = {};
for (const file of files) {
if (!file.endsWith(".json") || file === "_meta.json" || file === "subscriptions.json" || file === "auto-queue.json") continue;
try {
const raw = await fs.readFile(path.join(historyDir, file), "utf-8");
const id = file.replace(".json", "");
sessions[id] = JSON.parse(raw);
} catch {}
}
// Load subscriptions
let subscriptions = [];
try {
subscriptions = JSON.parse(await fs.readFile(path.join(historyDir, "subscriptions.json"), "utf-8")).subscriptions || [];
} catch {}
const exportData = {
version: 1,
exportedAt: new Date().toISOString(),
meta,
sessions,
subscriptions,
};
res.setHeader("Content-Type", "application/json");
res.setHeader("Content-Disposition", 'attachment; filename="youtube-summarizer-library.json"');
res.json(exportData);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post("/api/library/import", express.json({ limit: "200mb" }), async (req, res) => {
try {
const data = req.body;
if (!data || !data.sessions) {
return res.status(400).json({ error: "Invalid library file — missing sessions data" });
}
let imported = 0;
let skipped = 0;
// Import sessions
for (const [id, session] of Object.entries(data.sessions)) {
const filePath = path.join(historyDir, `${id}.json`);
// Skip if session already exists (don't overwrite)
try {
await fs.access(filePath);
skipped++;
continue;
} catch {}
await fs.writeFile(filePath, JSON.stringify(session));
imported++;
}
// Merge meta (add imported sessions to uncategorized if not already placed)
if (data.meta) {
const existingMeta = await loadMeta();
const allExistingIds = new Set([
...existingMeta.uncategorized,
...existingMeta.folders.flatMap(f => f.items),
]);
// Import folders that don't exist
if (data.meta.folders) {
for (const folder of data.meta.folders) {
const existingFolder = existingMeta.folders.find(f => f.id === folder.id);
if (!existingFolder) {
existingMeta.folders.push(folder);
folder.items.forEach(id => allExistingIds.add(id));
}
}
}
// Add any uncategorized items that aren't already placed
if (data.meta.uncategorized) {
for (const id of data.meta.uncategorized) {
if (!allExistingIds.has(id)) {
existingMeta.uncategorized.unshift(id);
}
}
}
await saveMeta(existingMeta);
}
// Import subscriptions (merge, don't duplicate)
if (data.subscriptions && data.subscriptions.length > 0) {
let existingSubs = [];
try {
existingSubs = JSON.parse(await fs.readFile(path.join(historyDir, "subscriptions.json"), "utf-8")).subscriptions || [];
} catch {}
const existingUrls = new Set(existingSubs.map(s => s.url));
let subsAdded = 0;
for (const sub of data.subscriptions) {
if (!existingUrls.has(sub.url)) {
existingSubs.push(sub);
subsAdded++;
}
}
await fs.writeFile(path.join(historyDir, "subscriptions.json"), JSON.stringify({ subscriptions: existingSubs }));
}
res.json({ ok: true, imported, skipped, total: Object.keys(data.sessions).length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── Subscriptions ─────────────────────────────────────────────────────────
const subsPath = path.join(historyDir, "subscriptions.json");
@@ -949,6 +1110,20 @@ async function fetchChannelName(url) {
return "Unknown Channel";
}
// ── In-process write serialization ────────────────────────────────────────
// Per-file promise chain to prevent lost-update races on read-modify-write
// state files (skip-list, seen-list). Without this, two concurrent DELETE
// handlers can each load the same snapshot, add their own id, and the
// second write overwrites the first — silently dropping entries.
const _fileLocks = new Map();
function withFileLock(key, fn) {
const prev = _fileLocks.get(key) || Promise.resolve();
const next = prev.then(fn, fn); // run fn whether prev resolved or rejected
// Keep the chain alive but don't leak errors to the next caller
_fileLocks.set(key, next.catch(() => {}));
return next;
}
// Skip list — videos deleted from history that subscriptions should not re-add
const skipPath = path.join(historyDir, "skip-list.json");
@@ -961,9 +1136,11 @@ async function loadSkipList() {
}
async function addToSkipList(videoId) {
const skipIds = await loadSkipList();
skipIds.add(videoId);
await fs.writeFile(skipPath, JSON.stringify({ videoIds: [...skipIds] }));
return withFileLock(skipPath, async () => {
const skipIds = await loadSkipList();
skipIds.add(videoId);
await fs.writeFile(skipPath, JSON.stringify({ videoIds: [...skipIds] }));
});
}
// Seen list — videos already offered for approval (persists across restarts)
@@ -978,9 +1155,11 @@ async function loadSeenList() {
}
async function addToSeenList(videoIds) {
const seen = await loadSeenList();
for (const id of videoIds) seen.add(id);
await fs.writeFile(seenPath, JSON.stringify({ videoIds: [...seen] }));
return withFileLock(seenPath, async () => {
const seen = await loadSeenList();
for (const id of videoIds) seen.add(id);
await fs.writeFile(seenPath, JSON.stringify({ videoIds: [...seen] }));
});
}
// Get all videoIds already processed in history
@@ -1236,6 +1415,13 @@ async function checkSubscriptions() {
}
async function _checkSubscriptionsInner() {
// Pro-tier feature: skip silently when not entitled. The HTTP gate above
// returns 402 to callers; this guards the background timer + manual paths.
if (!LIC.entitlements.has("subscriptions")) {
subCheckLog = [];
subLog("Skipped: subscriptions require a Pro license.");
return;
}
subCheckLog = []; // Clear logs for fresh check
const subs = await loadSubscriptions();
if (subs.length === 0) { subLog("No subscriptions found"); return; }
@@ -1503,7 +1689,7 @@ function channelKeyFromUrl(url) {
}
app.post("/api/subscriptions", async (req, res) => {
const { url, since, type } = req.body;
const { url, since, type, autoDownload } = req.body;
if (!url) return res.status(400).json({ error: "Missing url" });
const isPodcast = type === "podcast" || isPodcastFeedUrl(url);
@@ -1534,6 +1720,7 @@ app.post("/api/subscriptions", async (req, res) => {
createdAt: cutoff,
lastChecked: null,
paused: false,
autoDownload: autoDownload === true,
};
subs.push(sub);
await saveSubscriptions(subs);
@@ -1762,6 +1949,8 @@ app.get("/api/processing/log", (req, res) => {
app.post("/api/process", async (req, res) => {
const { url, apiKey: clientKey, model, type: itemType, title: itemTitle, uploadDate: itemUploadDate, episodeId } = req.body;
// BYO Gemini key is a Core-tier feature; the activation gate already
// ensures the caller is licensed, so no further check is needed here.
const apiKey = resolveApiKey(clientKey);
if (!url) {