allow easy custom branding in settings + fix some auth shit

This commit is contained in:
KingKDot
2026-07-17 20:09:47 -05:00
parent f404f22b29
commit 1cdeae0bc3
10 changed files with 559 additions and 2 deletions
+225 -1
View File
@@ -108,6 +108,20 @@ const appearanceForm = document.getElementById("appearance-form");
const appearancePermissionNote = document.getElementById("appearance-permission-note");
const appearanceSaveBtn = document.getElementById("appearance-save-btn");
const appearanceCustomCssInput = document.getElementById("appearance-custom-css");
const simpleThemeEnabledInput = document.getElementById("simple-theme-enabled");
const simpleThemeControls = document.getElementById("simple-theme-controls");
const simpleThemeAccentInput = document.getElementById("simple-theme-accent");
const simpleThemeBackgroundInput = document.getElementById("simple-theme-background");
const simpleThemeSurfaceInput = document.getElementById("simple-theme-surface");
const simpleThemeTextInput = document.getElementById("simple-theme-text");
const simpleThemeCornersInput = document.getElementById("simple-theme-corners");
const simpleThemeResetBtn = document.getElementById("simple-theme-reset");
const simpleThemePreview = document.getElementById("simple-theme-preview");
const simpleThemePreviewBar = document.getElementById("simple-theme-preview-bar");
const simpleThemePreviewBody = document.getElementById("simple-theme-preview-body");
const simpleThemePreviewDot = document.getElementById("simple-theme-preview-dot");
const simpleThemePreviewMuted = document.getElementById("simple-theme-preview-muted");
const simpleThemePreviewButton = document.getElementById("simple-theme-preview-button");
const brandProductNameInput = document.getElementById("brand-product-name");
const brandTabNameInput = document.getElementById("brand-tab-name");
const brandFaviconUrlInput = document.getElementById("brand-favicon-url");
@@ -1409,11 +1423,218 @@ const brandInputs = [
];
function setAppearanceFormDisabled(disabled) {
for (const input of [...brandInputs, appearanceCustomCssInput, appearanceSaveBtn]) {
const simpleThemeInputs = [
simpleThemeEnabledInput,
simpleThemeAccentInput,
simpleThemeBackgroundInput,
simpleThemeSurfaceInput,
simpleThemeTextInput,
simpleThemeCornersInput,
simpleThemeResetBtn,
];
for (const input of [...brandInputs, ...simpleThemeInputs, appearanceCustomCssInput, appearanceSaveBtn]) {
if (input) input.disabled = disabled;
}
}
const SIMPLE_THEME_START = "/* === OVERLORD SIMPLE THEME START === */";
const SIMPLE_THEME_END = "/* === OVERLORD SIMPLE THEME END === */";
const SIMPLE_THEME_BLOCK_RE = /\/\* === OVERLORD SIMPLE THEME START === \*\/[\s\S]*?\/\* === OVERLORD SIMPLE THEME END === \*\/\s*/;
const SIMPLE_THEME_DEFAULTS = Object.freeze({
accent: "#7a5bff",
background: "#020617",
surface: "#0f172a",
text: "#f1f5f9",
corners: "soft",
});
function normalizeThemeColor(value, fallback) {
return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? String(value).toLowerCase() : fallback;
}
function mixThemeColors(colorA, colorB, amount) {
const parse = (color) => [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16));
const a = parse(colorA);
const b = parse(colorB);
return `#${a.map((channel, i) => Math.round(channel + ((b[i] - channel) * amount)).toString(16).padStart(2, "0")).join("")}`;
}
function themeColorRgba(color, alpha) {
const channels = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16));
return `rgba(${channels.join(", ")}, ${alpha})`;
}
function getSimpleThemeSettings() {
return {
accent: normalizeThemeColor(simpleThemeAccentInput?.value, SIMPLE_THEME_DEFAULTS.accent),
background: normalizeThemeColor(simpleThemeBackgroundInput?.value, SIMPLE_THEME_DEFAULTS.background),
surface: normalizeThemeColor(simpleThemeSurfaceInput?.value, SIMPLE_THEME_DEFAULTS.surface),
text: normalizeThemeColor(simpleThemeTextInput?.value, SIMPLE_THEME_DEFAULTS.text),
corners: ["sharp", "soft", "round"].includes(simpleThemeCornersInput?.value)
? simpleThemeCornersInput.value
: SIMPLE_THEME_DEFAULTS.corners,
};
}
function applySimpleThemeSettings(settings = SIMPLE_THEME_DEFAULTS) {
const normalized = {
accent: normalizeThemeColor(settings.accent, SIMPLE_THEME_DEFAULTS.accent),
background: normalizeThemeColor(settings.background, SIMPLE_THEME_DEFAULTS.background),
surface: normalizeThemeColor(settings.surface, SIMPLE_THEME_DEFAULTS.surface),
text: normalizeThemeColor(settings.text, SIMPLE_THEME_DEFAULTS.text),
corners: ["sharp", "soft", "round"].includes(settings.corners) ? settings.corners : SIMPLE_THEME_DEFAULTS.corners,
};
if (simpleThemeAccentInput) simpleThemeAccentInput.value = normalized.accent;
if (simpleThemeBackgroundInput) simpleThemeBackgroundInput.value = normalized.background;
if (simpleThemeSurfaceInput) simpleThemeSurfaceInput.value = normalized.surface;
if (simpleThemeTextInput) simpleThemeTextInput.value = normalized.text;
if (simpleThemeCornersInput) simpleThemeCornersInput.value = normalized.corners;
updateSimpleThemePreview();
}
function buildSimpleThemeCss(settings) {
const { accent, background, surface, text, corners } = settings;
const accentShades = {
50: mixThemeColors(accent, "#ffffff", 0.92),
100: mixThemeColors(accent, "#ffffff", 0.82),
200: mixThemeColors(accent, "#ffffff", 0.68),
300: mixThemeColors(accent, "#ffffff", 0.48),
400: mixThemeColors(accent, "#ffffff", 0.24),
500: accent,
600: mixThemeColors(accent, "#000000", 0.15),
700: mixThemeColors(accent, "#000000", 0.3),
800: mixThemeColors(accent, "#000000", 0.45),
900: mixThemeColors(accent, "#000000", 0.58),
950: mixThemeColors(accent, "#000000", 0.72),
};
const neutralShades = {
50: mixThemeColors(text, "#ffffff", 0.35),
100: text,
200: mixThemeColors(surface, text, 0.86),
300: mixThemeColors(surface, text, 0.72),
400: mixThemeColors(surface, text, 0.55),
500: mixThemeColors(surface, text, 0.4),
600: mixThemeColors(surface, text, 0.26),
700: mixThemeColors(surface, text, 0.16),
800: mixThemeColors(surface, text, 0.08),
900: surface,
950: background,
};
const radii = {
sharp: ["2px", "3px", "4px", "5px"],
soft: ["8px", "10px", "14px", "18px"],
round: ["14px", "18px", "24px", "30px"],
}[corners];
const paletteNames = ["sky", "blue", "cyan", "indigo", "violet", "purple", "fuchsia"];
const accentVariables = paletteNames.flatMap((name) => (
Object.entries(accentShades).map(([shade, value]) => ` --color-${name}-${shade}: ${value};`)
));
const neutralVariables = Object.entries(neutralShades).map(([shade, value]) => ` --color-slate-${shade}: ${value};`);
return [
SIMPLE_THEME_START,
`/* settings: ${JSON.stringify(settings)} */`,
":root {",
...accentVariables,
...neutralVariables,
` --color-primary: ${accent};`,
` --color-primary-hover: ${accentShades[400]};`,
` --color-text: ${text};`,
` --color-text-bright: ${neutralShades[50]};`,
` --color-text-muted: ${neutralShades[400]};`,
` --color-surface: ${themeColorRgba(surface, 0.72)};`,
` --color-surface-dark: ${themeColorRgba(background, 0.88)};`,
` --color-border: ${themeColorRgba(text, 0.12)};`,
` --ui-border-strong: ${themeColorRgba(accent, 0.45)};`,
` --ui-ring: ${themeColorRgba(accent, 0.28)};`,
` --radius-sm: ${radii[0]};`,
` --radius-md: ${radii[1]};`,
` --radius-lg: ${radii[2]};`,
` --radius-xl: ${radii[3]};`,
` --radius-2xl: ${radii[3]};`,
"}",
"html, body {",
` background-color: ${background} !important;`,
` color: ${text};`,
"}",
SIMPLE_THEME_END,
].join("\n");
}
function replaceSimpleThemeBlock(css, replacement = "") {
const withoutManagedBlock = String(css || "").replace(SIMPLE_THEME_BLOCK_RE, "").trim();
return [replacement, withoutManagedBlock].filter(Boolean).join("\n\n");
}
function syncSimpleThemeCss() {
if (!appearanceCustomCssInput) return;
const enabled = !!simpleThemeEnabledInput?.checked;
const settings = getSimpleThemeSettings();
const replacement = enabled ? buildSimpleThemeCss(settings) : "";
if (enabled && brandAccentColorInput) brandAccentColorInput.value = settings.accent;
appearanceCustomCssInput.value = replaceSimpleThemeBlock(appearanceCustomCssInput.value, replacement);
if (simpleThemeControls) simpleThemeControls.classList.toggle("opacity-50", !enabled);
}
function loadSimpleThemeFromCss(css) {
const block = String(css || "").match(SIMPLE_THEME_BLOCK_RE)?.[0] || "";
if (simpleThemeEnabledInput) simpleThemeEnabledInput.checked = !!block;
if (block) {
const settingsText = block.match(/\/\* settings: (\{.*\}) \*\//)?.[1];
try {
applySimpleThemeSettings(settingsText ? JSON.parse(settingsText) : SIMPLE_THEME_DEFAULTS);
} catch {
applySimpleThemeSettings(SIMPLE_THEME_DEFAULTS);
}
} else {
applySimpleThemeSettings(SIMPLE_THEME_DEFAULTS);
}
if (simpleThemeControls) simpleThemeControls.classList.toggle("opacity-50", !block);
}
function updateSimpleThemePreview() {
const { accent, background, surface, text, corners } = getSimpleThemeSettings();
const radius = { sharp: "4px", soft: "12px", round: "22px" }[corners];
if (simpleThemePreview) {
simpleThemePreview.style.borderColor = mixThemeColors(surface, text, 0.2);
simpleThemePreview.style.borderRadius = radius;
}
if (simpleThemePreviewBar) {
simpleThemePreviewBar.style.backgroundColor = mixThemeColors(surface, background, 0.35);
simpleThemePreviewBar.style.color = text;
}
if (simpleThemePreviewBody) {
simpleThemePreviewBody.style.backgroundColor = surface;
simpleThemePreviewBody.style.color = text;
}
if (simpleThemePreviewDot) simpleThemePreviewDot.style.backgroundColor = accent;
if (simpleThemePreviewMuted) simpleThemePreviewMuted.style.color = mixThemeColors(surface, text, 0.6);
if (simpleThemePreviewButton) {
simpleThemePreviewButton.style.backgroundColor = accent;
simpleThemePreviewButton.style.borderRadius = radius;
}
}
function initSimpleThemeBuilder() {
const inputs = [simpleThemeAccentInput, simpleThemeBackgroundInput, simpleThemeSurfaceInput, simpleThemeTextInput, simpleThemeCornersInput];
for (const input of inputs) {
input?.addEventListener("input", () => {
if (simpleThemeEnabledInput && !simpleThemeEnabledInput.checked) simpleThemeEnabledInput.checked = true;
if (input === simpleThemeAccentInput && brandAccentColorInput) brandAccentColorInput.value = simpleThemeAccentInput.value;
updateSimpleThemePreview();
syncSimpleThemeCss();
});
}
simpleThemeEnabledInput?.addEventListener("change", syncSimpleThemeCss);
simpleThemeResetBtn?.addEventListener("click", () => {
applySimpleThemeSettings(SIMPLE_THEME_DEFAULTS);
if (simpleThemeEnabledInput) simpleThemeEnabledInput.checked = true;
if (brandAccentColorInput) brandAccentColorInput.value = SIMPLE_THEME_DEFAULTS.accent;
syncSimpleThemeCss();
});
updateSimpleThemePreview();
}
function applyBrandingForm(loginBranding = {}) {
if (brandProductNameInput) brandProductNameInput.value = loginBranding.productName || "Overlord";
if (brandTabNameInput) brandTabNameInput.value = loginBranding.tabName || loginBranding.productName || "Overlord";
@@ -1558,6 +1779,7 @@ async function loadAppearanceSettings() {
}
const data = await res.json().catch(() => ({}));
if (appearanceCustomCssInput) appearanceCustomCssInput.value = data.customCSS || "";
loadSimpleThemeFromCss(data.customCSS || "");
applyBrandingForm(data.loginBranding || {});
setAppearanceFormDisabled(false);
} catch {
@@ -1571,6 +1793,7 @@ async function saveAppearanceSettings(event) {
return;
}
syncSimpleThemeCss();
const customCSS = appearanceCustomCssInput ? appearanceCustomCssInput.value : "";
if (customCSS.length > 51200) {
showMessage("CSS exceeds the 50 KB size limit.", "error");
@@ -2720,6 +2943,7 @@ async function init() {
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
if (oidcForm) oidcForm.addEventListener("submit", saveOidcSettings);
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
initSimpleThemeBuilder();
initBrandingUploads();
if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings);
if (inputArchiveUserForm) inputArchiveUserForm.addEventListener("submit", saveInputArchivePreference);
+61
View File
@@ -1405,6 +1405,67 @@
</label>
</div>
<fieldset class="rounded-xl border border-slate-700 bg-slate-950/50 p-4 space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-sm font-semibold text-slate-200">Simple theme</h3>
<p class="mt-1 text-xs text-slate-500">Pick the main colors and corner style. Overlord builds the CSS for you.</p>
</div>
<label class="inline-flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
<input id="simple-theme-enabled" type="checkbox" class="h-4 w-4 rounded border-slate-600 bg-slate-900 text-fuchsia-500 focus:ring-fuchsia-500" />
Use simple theme
</label>
</div>
<div id="simple-theme-controls" class="grid grid-cols-2 lg:grid-cols-5 gap-3">
<label class="block">
<span class="text-xs text-slate-400">Accent</span>
<input id="simple-theme-accent" type="color" value="#7a5bff" class="mt-1 h-10 w-full px-1 py-1 rounded-lg bg-slate-900 border border-slate-700 cursor-pointer" />
</label>
<label class="block">
<span class="text-xs text-slate-400">Page background</span>
<input id="simple-theme-background" type="color" value="#020617" class="mt-1 h-10 w-full px-1 py-1 rounded-lg bg-slate-900 border border-slate-700 cursor-pointer" />
</label>
<label class="block">
<span class="text-xs text-slate-400">Cards &amp; panels</span>
<input id="simple-theme-surface" type="color" value="#0f172a" class="mt-1 h-10 w-full px-1 py-1 rounded-lg bg-slate-900 border border-slate-700 cursor-pointer" />
</label>
<label class="block">
<span class="text-xs text-slate-400">Text</span>
<input id="simple-theme-text" type="color" value="#f1f5f9" class="mt-1 h-10 w-full px-1 py-1 rounded-lg bg-slate-900 border border-slate-700 cursor-pointer" />
</label>
<label class="block col-span-2 lg:col-span-1">
<span class="text-xs text-slate-400">Corners</span>
<select id="simple-theme-corners" class="mt-1 h-10 w-full px-3 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-fuchsia-500 text-sm">
<option value="sharp">Sharp</option>
<option value="soft" selected>Soft</option>
<option value="round">Round</option>
</select>
</label>
</div>
<div id="simple-theme-preview" class="overflow-hidden rounded-xl border border-slate-700">
<div id="simple-theme-preview-bar" class="flex items-center gap-2 px-3 py-2">
<span id="simple-theme-preview-dot" class="h-2.5 w-2.5 rounded-full"></span>
<span class="text-xs font-medium">Theme preview</span>
</div>
<div id="simple-theme-preview-body" class="flex items-center justify-between gap-3 p-3">
<div>
<div class="text-sm font-semibold">Cards, text, and buttons</div>
<div id="simple-theme-preview-muted" class="mt-0.5 text-xs">Your custom palette is shown here.</div>
</div>
<button id="simple-theme-preview-button" type="button" tabindex="-1" class="px-3 py-1.5 text-xs font-medium text-white">Primary</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<button id="simple-theme-reset" type="button" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-slate-800 hover:bg-slate-700 border border-slate-700 text-xs text-slate-200 transition-colors">
<i class="fa-solid fa-rotate-left"></i>Reset colors
</button>
<p class="text-xs text-slate-500">Changes are added to the managed block below. Your handwritten CSS is preserved.</p>
</div>
</fieldset>
<label class="block">
<span class="text-sm text-slate-300">Custom CSS</span>
<textarea
@@ -135,6 +135,12 @@ export async function handleClientCommandRoute(
metrics.recordCommand("screenshot");
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.SCREENSHOT, targetClientId: targetId, success: true });
} else if (action === "desktop_start") {
try {
requireFeatureAccess(user, "remote_desktop");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start", id: uuidv4() }));
metrics.recordCommand("desktop_start");
} else if (action === "darwin_request_permissions") {
@@ -138,6 +138,23 @@ describe("clients:disconnect gates", () => {
});
});
describe("remote desktop alternate command gates", () => {
test("operator with remote_desktop feature OFF cannot start the desktop stream", async () => {
const auth = await makeUserWithToken("operator");
const clientId = registerTestClient();
setUserFeaturePermission(auth.user.id, "remote_desktop", false);
const res = await command(auth.token, clientId, "desktop_start");
expect(res!.status).toBe(403);
});
test("operator with remote_desktop feature ON can start the desktop stream", async () => {
const auth = await makeUserWithToken("operator");
const clientId = registerTestClient();
const res = await command(auth.token, clientId, "desktop_start");
expect(res!.status).toBe(200);
});
});
describe("clients:reconnect gates", () => {
test("operator (default) can reconnect", async () => {
const auth = await makeUserWithToken("operator");
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, test } from "bun:test";
import { generateToken } from "../../auth";
import {
createUser,
deleteUser,
getUserById,
setUserClientAccessScope,
setUserFeaturePermission,
} from "../../users";
import { handleWebrtcRoutes } from "./webrtc-routes";
const PASSWORD = "Aa1!WebrtcAuthTestPass_2026";
const createdUserIds: number[] = [];
async function makeOperator() {
const username = `wa_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const result = await createUser(username, PASSWORD, "operator", "test");
expect(result.success).toBe(true);
const user = getUserById(result.userId!);
expect(user).not.toBeNull();
createdUserIds.push(user!.id);
setUserClientAccessScope(user!.id, "all");
return { user: user!, token: await generateToken(user!) };
}
afterEach(() => {
while (createdUserIds.length) deleteUser(createdUserIds.pop()!);
});
describe("WebRTC viewer authorization", () => {
test("denies WHEP negotiation when the matching feature is disabled", async () => {
const auth = await makeOperator();
setUserFeaturePermission(auth.user.id, "remote_desktop", false);
const url = new URL("https://localhost/api/webrtc/agents/client-a/desktop/whep");
const response = await handleWebrtcRoutes(
new Request(url, {
method: "POST",
headers: { Authorization: `Bearer ${auth.token}` },
body: "offer",
}),
url,
);
expect(response!.status).toBe(403);
});
test("uses the feature corresponding to each media kind", async () => {
const auth = await makeOperator();
setUserFeaturePermission(auth.user.id, "voice", false);
const url = new URL("https://localhost/api/webrtc/agents/client-a/audio/whep");
const response = await handleWebrtcRoutes(
new Request(url, {
method: "POST",
headers: { Authorization: `Bearer ${auth.token}` },
body: "offer",
}),
url,
);
expect(response!.status).toBe(403);
});
});
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto";
import { authenticateRequest } from "../../auth";
import { requireClientAccess } from "../../rbac";
import { requireClientAccess, requireFeatureAccess } from "../../rbac";
import { logger } from "../../logger";
import { issueTurnIceServers } from "../turn-credentials";
@@ -27,6 +27,13 @@ export function issueWebrtcPublishToken(clientId: string): string {
export type WebrtcKind = "desktop" | "backstage" | "webcam" | "audio";
const WEBRTC_FEATURES = {
desktop: "remote_desktop",
backstage: "backstage",
webcam: "webcam",
audio: "voice",
} as const;
export function webrtcStreamPathFor(clientId: string, kind: WebrtcKind): string {
return `agents/${clientId}/${kind}`;
}
@@ -78,6 +85,8 @@ export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Respon
if (!user) return new Response("Unauthorized", { status: 401 });
try {
requireClientAccess(user, clientId);
const streamKind = streamPath.split("/")[2] as WebrtcKind;
requireFeatureAccess(user, WEBRTC_FEATURES[streamKind]);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -24,6 +24,12 @@ import { stopAllProxiesForClient } from "../socks5-proxy-manager";
import { verifyBuildToken, isBuildBanned } from "../build-signing";
import { clearThumbnail } from "../../thumbnails";
import { stopRemoteDesktopRecording } from "../rd-recording";
import {
isAuthenticatedViewerRole,
registerViewerSocket,
unregisterViewerSocket,
validateViewerAuthorization,
} from "../viewer-authorization";
const OFFLINE_GRACE_MS = (() => {
const raw = process.env.OVERLORD_OFFLINE_GRACE_MS;
@@ -324,6 +330,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
const role = ws.data.role as string;
const clientId = ws.data.clientId;
const ip = ws.data.ip;
if (isAuthenticatedViewerRole(ws.data.role) && !registerViewerSocket(ws)) return;
if (role === "dashboard_viewer") return deps.handleDashboardViewerOpen(ws);
if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws);
if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws);
@@ -375,6 +382,7 @@ export async function handleWebSocketMessage(
}
const socketRole = ws.data.role as string;
if (isAuthenticatedViewerRole(ws.data.role) && !validateViewerAuthorization(ws)) return;
if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message);
if (socketRole === "rd_viewer") return deps.handleRemoteDesktopViewerMessage(ws, message);
if (socketRole === "webcam_viewer") return deps.handleWebcamViewerMessage(ws, message);
@@ -973,6 +981,7 @@ export function handleWebSocketClose(
reason: unknown,
deps: WsLifecycleDeps,
): void {
unregisterViewerSocket(ws);
const clientId = ws.data.clientId;
const role = ws.data.role as string;
const sessionId = ws.data.sessionId;
@@ -226,6 +226,8 @@ async function tryGlobalViewerUpgrade(
const denied = route.authorize?.(user);
if (denied) return denied;
const token = extractTokenFromRequest(req);
return upgradeOrFail(req, server, {
role: route.role,
clientId: "",
@@ -233,6 +235,7 @@ async function tryGlobalViewerUpgrade(
userRole: user.role,
userId: user.userId,
username: user.username,
...(token ? { authTokenHash: hashTokenForSession(token) } : {}),
});
}
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, test } from "bun:test";
import { generateToken } from "../auth";
import { hashTokenForSession, revokeSessionByTokenHash } from "../db";
import type { SocketData } from "../sessions/types";
import {
createUser,
deleteUser,
getUserById,
setUserClientAccessScope,
setUserFeaturePermission,
} from "../users";
import { validateViewerAuthorization } from "./viewer-authorization";
const PASSWORD = "Aa1!ViewerRevalidationTest_2026";
const createdUserIds: number[] = [];
async function makeViewerSocket(role: SocketData["role"], clientId = "client-a") {
const username = `wr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const result = await createUser(username, PASSWORD, "operator", "test");
expect(result.success).toBe(true);
const user = getUserById(result.userId!);
expect(user).not.toBeNull();
createdUserIds.push(user!.id);
setUserClientAccessScope(user!.id, "all");
const token = await generateToken(user!);
const closes: Array<{ code: number; reason: string }> = [];
const ws = {
data: {
role,
clientId,
userId: user!.id,
userRole: user!.role,
username: user!.username,
authTokenHash: hashTokenForSession(token),
},
close(code: number, reason: string) {
closes.push({ code, reason });
},
} as any;
return { user: user!, token, ws, closes };
}
afterEach(() => {
while (createdUserIds.length) deleteUser(createdUserIds.pop()!);
});
describe("live viewer WebSocket authorization", () => {
test("closes a socket after its login session is revoked", async () => {
const auth = await makeViewerSocket("rd_viewer");
expect(validateViewerAuthorization(auth.ws)).toBe(true);
revokeSessionByTokenHash(hashTokenForSession(auth.token));
expect(validateViewerAuthorization(auth.ws)).toBe(false);
expect(auth.closes).toEqual([{ code: 1008, reason: "Authentication expired" }]);
});
test("closes a socket when its feature is revoked after connection", async () => {
const auth = await makeViewerSocket("rd_viewer");
expect(validateViewerAuthorization(auth.ws)).toBe(true);
setUserFeaturePermission(auth.user.id, "remote_desktop", false);
expect(validateViewerAuthorization(auth.ws)).toBe(false);
expect(auth.closes[0]?.reason).toBe("Forbidden: feature access denied");
});
test("closes a socket when client scope is removed after connection", async () => {
const auth = await makeViewerSocket("console_viewer");
expect(validateViewerAuthorization(auth.ws)).toBe(true);
setUserClientAccessScope(auth.user.id, "none");
expect(validateViewerAuthorization(auth.ws)).toBe(false);
expect(auth.closes[0]?.reason).toBe("Forbidden: client access denied");
});
});
@@ -0,0 +1,97 @@
import type { ServerWebSocket } from "bun";
import { getSessionByTokenHash } from "../db";
import { hasPermission } from "../rbac";
import type { SocketData, SocketRole } from "../sessions/types";
import {
canUserAccessClient,
canUserAccessFeature,
getUserById,
type FeatureName,
} from "../users";
const VIEWER_FEATURES: Partial<Record<SocketRole, FeatureName>> = {
console_viewer: "console",
rd_viewer: "remote_desktop",
backstage_viewer: "backstage",
webcam_viewer: "webcam",
file_browser_viewer: "file_browser",
process_viewer: "processes",
keylogger_viewer: "keylogger",
voice_viewer: "voice",
desktop_audio_viewer: "voice",
};
const GLOBAL_VIEWER_ROLES = new Set<SocketRole>([
"notifications_viewer",
"dashboard_viewer",
"chat_viewer",
]);
const activeViewerSockets = new Set<ServerWebSocket<SocketData>>();
function deny(ws: ServerWebSocket<SocketData>, reason: string): false {
activeViewerSockets.delete(ws);
try {
ws.close(1008, reason);
} catch {}
return false;
}
export function isAuthenticatedViewerRole(role: SocketRole): boolean {
return VIEWER_FEATURES[role] !== undefined || GLOBAL_VIEWER_ROLES.has(role);
}
export function validateViewerAuthorization(ws: ServerWebSocket<SocketData>): boolean {
const role = ws.data?.role;
if (!isAuthenticatedViewerRole(role)) return true;
const { authTokenHash, userId } = ws.data;
if (!authTokenHash || userId === undefined) {
return deny(ws, "Authentication expired");
}
const session = getSessionByTokenHash(authTokenHash);
const now = Math.floor(Date.now() / 1000);
if (!session || session.userId !== userId || session.expiresAt <= now) {
return deny(ws, "Authentication expired");
}
const user = getUserById(userId);
if (!user) return deny(ws, "Authentication expired");
ws.data.userRole = user.role;
ws.data.username = user.username;
const feature = VIEWER_FEATURES[role];
if (feature) {
if (!canUserAccessClient(userId, user.role, ws.data.clientId)) {
return deny(ws, "Forbidden: client access denied");
}
if (!canUserAccessFeature(userId, user.role, feature)) {
return deny(ws, "Forbidden: feature access denied");
}
}
if (role === "chat_viewer" && !hasPermission(user.role, "chat:write", userId)) {
return deny(ws, "Forbidden: chat access denied");
}
return true;
}
export function registerViewerSocket(ws: ServerWebSocket<SocketData>): boolean {
if (!validateViewerAuthorization(ws)) return false;
activeViewerSockets.add(ws);
return true;
}
export function unregisterViewerSocket(ws: ServerWebSocket<SocketData>): void {
activeViewerSockets.delete(ws);
}
export function revalidateActiveViewerSockets(): void {
for (const ws of activeViewerSockets) validateViewerAuthorization(ws);
}
const viewerAuthTimer = setInterval(revalidateActiveViewerSockets, 5_000);
(viewerAuthTimer as any).unref?.();