security update + make easter egg optional.

This commit is contained in:
KingKDot
2026-08-01 01:54:07 -05:00
parent bd7b3ea6f4
commit 34158ba673
22 changed files with 534 additions and 90 deletions
+1
View File
@@ -15,6 +15,7 @@ dist/
# Build output # Build output
Overlord-Server/public/assets/tailwind.css Overlord-Server/public/assets/tailwind.css
Overlord-Server/public/vendor/ Overlord-Server/public/vendor/
Overlord-Server/public/assets/generated/
Overlord-Desktop/src/vendor/ Overlord-Desktop/src/vendor/
# Tauri # Tauri
+6
View File
@@ -15,10 +15,12 @@
"fuse.js": "^7.4.1", "fuse.js": "^7.4.1",
"geoip-lite": "latest", "geoip-lite": "latest",
"gridstack": "^12.6.0", "gridstack": "^12.6.0",
"hono": "^4.12.33",
"jose": "^6.1.3", "jose": "^6.1.3",
"msgpackr": "1.11.8", "msgpackr": "1.11.8",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"valibot": "^1.4.2",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"winston": "^3.11.0", "winston": "^3.11.0",
}, },
@@ -342,6 +344,8 @@
"highlight.js": ["highlight.js@11.9.0", "", {}, "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw=="], "highlight.js": ["highlight.js@11.9.0", "", {}, "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw=="],
"hono": ["hono@4.12.33", "", {}, "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ=="],
"html-minifier-terser": ["html-minifier-terser@7.2.0", "", { "dependencies": { "camel-case": "^4.1.2", "clean-css": "~5.3.2", "commander": "^10.0.0", "entities": "^4.4.0", "param-case": "^3.0.4", "relateurl": "^0.2.7", "terser": "^5.15.1" }, "bin": { "html-minifier-terser": "cli.js" } }, "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA=="], "html-minifier-terser": ["html-minifier-terser@7.2.0", "", { "dependencies": { "camel-case": "^4.1.2", "clean-css": "~5.3.2", "commander": "^10.0.0", "entities": "^4.4.0", "param-case": "^3.0.4", "relateurl": "^0.2.7", "terser": "^5.15.1" }, "bin": { "html-minifier-terser": "cli.js" } }, "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA=="],
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
@@ -554,6 +558,8 @@
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+7 -3
View File
@@ -6,13 +6,15 @@
"scripts": { "scripts": {
"test": "bun run scripts/run-tests-isolated.ts", "test": "bun run scripts/run-tests-isolated.ts",
"test:e2e": "bun run test:e2e:prepare && playwright test", "test:e2e": "bun run test:e2e:prepare && playwright test",
"test:e2e:prepare": "bun run build:css && bun run vendor", "test:e2e:prepare": "bun run build:css && bun run build:web && bun run vendor",
"test:e2e:install": "playwright install chromium", "test:e2e:install": "playwright install chromium",
"dev": "bun run scripts/dev.ts", "dev": "bun run scripts/dev.ts",
"start": "bun run build && bun dist/index.js", "start": "bun run build && bun dist/index.js",
"build": "bun run build:css && bun run vendor && bun run build:bundle", "build": "bun run build:css && bun run build:web && bun run vendor && bun run build:bundle",
"build:public:prod": "bun run build:css && bun run vendor && bun run minify", "build:public:prod": "bun run build:css && bun run build:web && bun run vendor && bun run minify",
"build:bundle": "bun build --production src/index.ts --target bun --outfile dist/index.js && bun build --production src/server/plugin-runtime/worker-host.ts --target bun --outfile dist/server/plugin-runtime/worker-host.js", "build:bundle": "bun build --production src/index.ts --target bun --outfile dist/index.js && bun build --production src/server/plugin-runtime/worker-host.ts --target bun --outfile dist/server/plugin-runtime/worker-host.js",
"build:web": "bun build src/web/shared-ui-settings.ts --outdir public/assets/generated --target browser --format esm --sourcemap=external",
"watch:web": "bun run build:web --watch",
"vendor": "bun run scripts/vendor.ts", "vendor": "bun run scripts/vendor.ts",
"build:prod:win": "bun run build:public:prod && bun build --production src/index.ts --compile --target bun-windows-x64 --outfile dist/overlord-server.exe", "build:prod:win": "bun run build:public:prod && bun build --production src/index.ts --compile --target bun-windows-x64 --outfile dist/overlord-server.exe",
"build:prod:linux": "bun run build:public:prod && bun build --production src/index.ts --compile --target bun-linux-x64 --outfile dist/overlord-server-linux-x64", "build:prod:linux": "bun run build:public:prod && bun build --production src/index.ts --compile --target bun-linux-x64 --outfile dist/overlord-server-linux-x64",
@@ -35,10 +37,12 @@
"fuse.js": "^7.4.1", "fuse.js": "^7.4.1",
"geoip-lite": "latest", "geoip-lite": "latest",
"gridstack": "^12.6.0", "gridstack": "^12.6.0",
"hono": "^4.12.33",
"jose": "^6.1.3", "jose": "^6.1.3",
"msgpackr": "1.11.8", "msgpackr": "1.11.8",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"valibot": "^1.4.2",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
+1 -1
View File
@@ -3,7 +3,7 @@ import { checkFeatureAccess } from "./feature-gate.js";
import { createKeyboardCapture } from "./keyboard-capture.js"; import { createKeyboardCapture } from "./keyboard-capture.js";
import { WhepClient } from "./whep.js"; import { WhepClient } from "./whep.js";
import { P2PClient } from "./webrtc-p2p.js"; import { P2PClient } from "./webrtc-p2p.js";
import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js"; import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/shared-ui-settings.js";
(async function () { (async function () {
const urlParams = new URLSearchParams(location.search); const urlParams = new URLSearchParams(location.search);
@@ -0,0 +1,65 @@
const EASTER_EGG_ENABLED_KEY = "overlord_easter_egg_enabled";
const EASTER_EGG_SHOWN_KEY = "overlord_easter_egg_shown";
const EASTER_EGG_URL = "/assets/console.gif";
let activeRender = null;
export function isEasterEggEnabled() {
try {
return localStorage.getItem(EASTER_EGG_ENABLED_KEY) === "true";
} catch {
return false;
}
}
export function setEasterEggEnabled(enabled) {
try {
localStorage.setItem(EASTER_EGG_ENABLED_KEY, String(Boolean(enabled)));
} catch {}
if (!enabled) {
try {
sessionStorage.removeItem(EASTER_EGG_SHOWN_KEY);
} catch {}
}
}
function wasShownThisSession() {
try {
return sessionStorage.getItem(EASTER_EGG_SHOWN_KEY) === "true";
} catch {
return false;
}
}
function markShownThisSession() {
try {
sessionStorage.setItem(EASTER_EGG_SHOWN_KEY, "true");
} catch {}
}
export async function showEnabledEasterEgg({ force = false } = {}) {
if (!isEasterEggEnabled() || (!force && wasShownThisSession())) return false;
if (activeRender) return activeRender;
activeRender = (async () => {
const response = await fetch(EASTER_EGG_URL);
if (!response.ok) throw new Error(`Easter egg failed to load (${response.status})`);
const blob = await response.blob();
if (!blob.type.startsWith("image/")) throw new Error("Easter egg response was not an image");
const objectUrl = URL.createObjectURL(blob);
console.log(
"%c ",
`background:url("${objectUrl}") left top/contain no-repeat;font-size:320px;background-color:transparent`,
);
markShownThisSession();
setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
return true;
})().finally(() => {
activeRender = null;
});
return activeRender;
}
+3 -37
View File
@@ -15,6 +15,7 @@ import {
} from "./data.js"; } from "./data.js";
import { ThumbnailLoader } from "./thumbnail-loader.js"; import { ThumbnailLoader } from "./thumbnail-loader.js";
import { initDashboardStats, updateDashboardStatsFromClients } from "./dashboard-stats.js"; import { initDashboardStats, updateDashboardStatsFromClients } from "./dashboard-stats.js";
import { showEnabledEasterEgg } from "./easter-egg.js";
const grid = document.getElementById("grid"); const grid = document.getElementById("grid");
const totalPill = document.getElementById("total-pill"); const totalPill = document.getElementById("total-pill");
@@ -2243,40 +2244,5 @@ menu.addEventListener("click", async (e) => {
loadCurrentUser(); loadCurrentUser();
// console easter egg // Opt-in console easter egg. Disabled users never request the large image.
(function () { showEnabledEasterEgg().catch((err) => console.warn(err.message));
const runWhenIdle = (fn) => {
if ("requestIdleCallback" in window) {
window.requestIdleCallback(fn, { timeout: 5000 });
return;
}
window.addEventListener("load", () => setTimeout(fn, 1500), { once: true });
};
const consoleimg = {
load: function (src, { size = 320, color = "transparent" } = {}) {
const reader = new FileReader();
reader.addEventListener("load", function () {
const style =
"background: url('" + reader.result + "') left top no-repeat; font-size: " +
size +
"px; background-size: contain; background-color:" +
color;
console.log("%c ", style);
}, false);
fetch(src)
.then((r) => r.blob())
.then((blob) => {
if (blob.type.indexOf("image") === 0) {
if (blob.size > 8192 && navigator.userAgent.indexOf("Firefox") > 0)
throw new Error("Image size too big to be displayed in Firefox.");
return blob;
}
throw new Error("Valid image not found.");
})
.then((blob) => reader.readAsDataURL(blob))
.catch((err) => console.warn(err.message));
},
};
runWhenIdle(() => consoleimg.load("/assets/console.gif", { size: 320, color: "transparent" }));
})();
@@ -4,7 +4,7 @@ import { WhepClient } from "./whep.js";
import { P2PClient } from "./webrtc-p2p.js"; import { P2PClient } from "./webrtc-p2p.js";
import { AdaptiveDesktopQuality, normalizeAdaptiveProfiles } from "./rd-adaptive-quality.js"; import { AdaptiveDesktopQuality, normalizeAdaptiveProfiles } from "./rd-adaptive-quality.js";
import { createKeyboardCapture } from "./keyboard-capture.js"; import { createKeyboardCapture } from "./keyboard-capture.js";
import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js"; import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/shared-ui-settings.js";
(async function () { (async function () {
const clientId = new URLSearchParams(location.search).get("clientId"); const clientId = new URLSearchParams(location.search).get("clientId");
+40
View File
@@ -7,6 +7,11 @@ import {
} from "./notify-client.js"; } from "./notify-client.js";
import { escapeHtml, formatBytes as formatSharedBytes, formatDate as formatSharedDate } from "./format.js"; import { escapeHtml, formatBytes as formatSharedBytes, formatDate as formatSharedDate } from "./format.js";
import { getCertificateTrustGuide } from "./cert-banner.js"; import { getCertificateTrustGuide } from "./cert-banner.js";
import {
isEasterEggEnabled,
setEasterEggEnabled,
showEnabledEasterEgg,
} from "./easter-egg.js";
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds"; const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
const NAV_MODE_KEY = "sb_mode"; const NAV_MODE_KEY = "sb_mode";
@@ -40,6 +45,8 @@ const prefNotificationsInput = document.getElementById("pref-notifications");
const prefDesktopNotificationsInput = document.getElementById("pref-desktop-notifications"); const prefDesktopNotificationsInput = document.getElementById("pref-desktop-notifications");
const prefDesktopNotificationsHint = document.getElementById("pref-desktop-notifications-hint"); const prefDesktopNotificationsHint = document.getElementById("pref-desktop-notifications-hint");
const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds"); const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds");
const easterEggEnabledInput = document.getElementById("easter-egg-enabled");
const easterEggStatus = document.getElementById("easter-egg-status");
const inputArchiveUserForm = document.getElementById("input-archive-user-form"); const inputArchiveUserForm = document.getElementById("input-archive-user-form");
const inputArchiveMyEnabledInput = document.getElementById("input-archive-my-enabled"); const inputArchiveMyEnabledInput = document.getElementById("input-archive-my-enabled");
@@ -656,6 +663,38 @@ function loadPrefs() {
} }
} }
function initEasterEggPreference() {
if (!easterEggEnabledInput) return;
easterEggEnabledInput.checked = isEasterEggEnabled();
easterEggEnabledInput.addEventListener("change", async () => {
const enabled = easterEggEnabledInput.checked;
setEasterEggEnabled(enabled);
if (!easterEggStatus) return;
if (!enabled) {
easterEggStatus.textContent = "Disabled. The image will not be requested.";
easterEggStatus.className = "text-xs text-slate-500";
return;
}
easterEggEnabledInput.disabled = true;
easterEggStatus.textContent = "Loading the easter egg…";
easterEggStatus.className = "text-xs text-violet-300";
try {
await showEnabledEasterEgg({ force: true });
easterEggStatus.textContent = "Enabled and shown in the browser console.";
easterEggStatus.className = "text-xs text-emerald-400";
} catch (error) {
setEasterEggEnabled(false);
easterEggEnabledInput.checked = false;
easterEggStatus.textContent = `Could not load the easter egg: ${error.message}`;
easterEggStatus.className = "text-xs text-rose-400";
} finally {
easterEggEnabledInput.disabled = false;
}
});
}
async function loadCurrentUser() { async function loadCurrentUser() {
const res = await fetch("/api/auth/me", { credentials: "include" }); const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) { if (!res.ok) {
@@ -2926,6 +2965,7 @@ async function init() {
await loadCurrentUser(); await loadCurrentUser();
applyPermissionVisibility(); applyPermissionVisibility();
loadPrefs(); loadPrefs();
initEasterEggPreference();
await loadMfaStatus(); await loadMfaStatus();
if (userHas("clients:build")) { if (userHas("clients:build")) {
+1 -1
View File
@@ -2,7 +2,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
import { checkFeatureAccess } from "./feature-gate.js"; import { checkFeatureAccess } from "./feature-gate.js";
import { WhepClient } from "./whep.js"; import { WhepClient } from "./whep.js";
import { P2PClient } from "./webrtc-p2p.js"; import { P2PClient } from "./webrtc-p2p.js";
import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js"; import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/shared-ui-settings.js";
(async function () { (async function () {
const clientId = new URLSearchParams(location.search).get("clientId"); const clientId = new URLSearchParams(location.search).get("clientId");
+22
View File
@@ -1781,6 +1781,28 @@
</div> </div>
</section> </section>
<!-- Easter egg -->
<section id="section-easter-egg" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-violet-500/10 border border-violet-500/30">
<i class="fa-solid fa-egg text-violet-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Easter egg</h2>
<p class="text-sm text-slate-400">Optionally show a hidden surprise in the browser console.</p>
</div>
</div>
<label class="flex items-start gap-3 rounded-lg border border-slate-800 bg-slate-900 px-4 py-3 cursor-pointer">
<input id="easter-egg-enabled" type="checkbox" class="mt-0.5 h-4 w-4 accent-violet-500" />
<span class="space-y-1">
<span class="block text-sm text-slate-200">Enable easter egg</span>
<span class="block text-xs text-slate-500">The image is about 5.9 MB and is only requested after you enable this option. Your browser caches it for later sessions.</span>
</span>
</label>
<p id="easter-egg-status" class="text-xs text-slate-500">Disabled by default.</p>
</section>
</div> </div>
<!-- /Sections --> <!-- /Sections -->
</div> </div>
+12 -1
View File
@@ -26,6 +26,7 @@ function runPreparation(command: string): void {
} }
runPreparation("build:css"); runPreparation("build:css");
runPreparation("build:web");
runPreparation("vendor"); runPreparation("vendor");
const backend = Bun.spawn(["bun", "--watch", "src/index.ts"], { const backend = Bun.spawn(["bun", "--watch", "src/index.ts"], {
@@ -44,6 +45,14 @@ const styles = Bun.spawn(["bun", "run", "watch:css"], {
stderr: "inherit", stderr: "inherit",
}); });
const web = Bun.spawn(["bun", "run", "watch:web"], {
cwd,
env,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
let stopping = false; let stopping = false;
function stop(exitCode = 0): void { function stop(exitCode = 0): void {
@@ -51,6 +60,7 @@ function stop(exitCode = 0): void {
stopping = true; stopping = true;
backend.kill(); backend.kill();
styles.kill(); styles.kill();
web.kill();
process.exitCode = exitCode; process.exitCode = exitCode;
} }
@@ -60,6 +70,7 @@ process.on("SIGTERM", () => stop(143));
const result = await Promise.race([ const result = await Promise.race([
backend.exited.then((exitCode) => ({ process: "backend", exitCode })), backend.exited.then((exitCode) => ({ process: "backend", exitCode })),
styles.exited.then((exitCode) => ({ process: "Tailwind watcher", exitCode })), styles.exited.then((exitCode) => ({ process: "Tailwind watcher", exitCode })),
web.exited.then((exitCode) => ({ process: "browser TypeScript watcher", exitCode })),
]); ]);
if (!stopping) { if (!stopping) {
@@ -67,4 +78,4 @@ if (!stopping) {
stop(result.exitCode || 1); stop(result.exitCode || 1);
} }
await Promise.allSettled([backend.exited, styles.exited]); await Promise.allSettled([backend.exited, styles.exited, web.exited]);
+9 -1
View File
@@ -58,6 +58,7 @@ import { handleRegistrationRoutes } from "./server/routes/registration-routes";
import { consumeHttpDownloadPayload, type PendingHttpDownload } from "./server/http-download-consumer"; import { consumeHttpDownloadPayload, type PendingHttpDownload } from "./server/http-download-consumer";
import { startBuildProcess as runBuildProcess } from "./server/build-process"; import { startBuildProcess as runBuildProcess } from "./server/build-process";
import { createHttpFetchHandler } from "./server/http-dispatch"; import { createHttpFetchHandler } from "./server/http-dispatch";
import { createHonoRouteHandler } from "./server/hono-router";
import { startMaintenanceLoops } from "./server/maintenance-loops"; import { startMaintenanceLoops } from "./server/maintenance-loops";
import { import {
deliverNotificationWithScreenshot, deliverNotificationWithScreenshot,
@@ -711,6 +712,13 @@ async function startServer() {
handleCrashReport: notificationPluginHandlers.handleCrashReport, handleCrashReport: notificationPluginHandlers.handleCrashReport,
}; };
const routedHttpHandler = createHonoRouteHandler([
{
basePath: "/api/users",
handler: (req, url, srv) => handleUsersRoutes(req, url, srv as any),
},
]);
const server = Bun.serve<SocketData>({ const server = Bun.serve<SocketData>({
port: PORT, port: PORT,
hostname: HOST, hostname: HOST,
@@ -742,7 +750,7 @@ async function startServer() {
(req, url) => handleEnrollmentRoutes(req, url), (req, url) => handleEnrollmentRoutes(req, url),
(req, url) => handleChatRoutes(req, url), (req, url) => handleChatRoutes(req, url),
(req, url) => handleSolRoutes(req, url), (req, url) => handleSolRoutes(req, url),
(req, url, srv) => handleUsersRoutes(req, url, srv as any), routedHttpHandler,
(req, url, srv) => handlePermissionGroupsRoutes(req, url, srv as any), (req, url, srv) => handlePermissionGroupsRoutes(req, url, srv as any),
(req, url, srv) => handleBuildRoutes(req, url, srv as any, routeDeps.build), (req, url, srv) => handleBuildRoutes(req, url, srv as any, routeDeps.build),
(req, url, srv) => handleDeployRoutes(req, url, srv as any, routeDeps.deploy), (req, url, srv) => handleDeployRoutes(req, url, srv as any, routeDeps.deploy),
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import { createHonoRouteHandler } from "./hono-router";
describe("createHonoRouteHandler", () => {
const routed = createHonoRouteHandler([
{
basePath: "/api/users",
handler: async (_req, url, server) => Response.json({
path: url.pathname,
server,
}),
},
]);
test("routes exact and nested group paths", async () => {
const server = { name: "test-server" };
const exactUrl = new URL("https://localhost/api/users");
const exact = await routed(new Request(exactUrl), exactUrl, server);
expect(exact?.status).toBe(200);
expect(await exact?.json()).toEqual({ path: "/api/users", server });
expect(exact?.headers.get("x-request-id")).toBeTruthy();
const nestedUrl = new URL("https://localhost/api/users/42/role");
const nested = await routed(new Request(nestedUrl), nestedUrl, server);
expect(nested?.status).toBe(200);
expect((await nested?.json()).path).toBe("/api/users/42/role");
});
test("does not claim similar or unrelated paths", async () => {
for (const path of ["/api/users-other", "/api/clients"]) {
const url = new URL(`https://localhost${path}`);
expect(await routed(new Request(url), url, {})).toBeNull();
}
});
test("falls through when a migrated handler does not own a nested route", async () => {
const fallthrough = createHonoRouteHandler([
{
basePath: "/api/users",
handler: async () => null,
},
]);
const url = new URL("https://localhost/api/users/42/permission-groups");
expect(await fallthrough(new Request(url), url, {})).toBeNull();
});
});
+57
View File
@@ -0,0 +1,57 @@
import { Hono, type Handler } from "hono";
import { requestId } from "hono/request-id";
import type { RouteHandler } from "./http-dispatch";
type RouterEnv = {
Bindings: {
server: unknown;
};
};
const FALLTHROUGH_HEADER = "X-Overlord-Route-Fallthrough";
export type HonoRouteGroup = {
basePath: `/${string}`;
handler: RouteHandler;
};
function ownsPath(basePath: string, pathname: string): boolean {
return pathname === basePath || pathname.startsWith(`${basePath}/`);
}
export function createHonoRouteHandler(groups: readonly HonoRouteGroup[]): RouteHandler {
const app = new Hono<RouterEnv>();
app.use("*", requestId());
app.use("*", async (context, next) => {
await next();
context.res.headers.set("X-Request-Id", context.get("requestId"));
});
for (const group of groups) {
const run: Handler<RouterEnv> = async (context) => {
const req = context.req.raw;
const response = await group.handler(
req,
new URL(req.url),
context.env.server,
);
return response ?? new Response(null, {
status: 404,
headers: { [FALLTHROUGH_HEADER]: "1" },
});
};
app.all(group.basePath, run);
app.all(`${group.basePath}/*`, run);
}
return async (req, url, server) => {
if (!groups.some((group) => ownsPath(group.basePath, url.pathname))) {
return null;
}
const response = await app.fetch(req, { server });
if (response.headers.get(FALLTHROUGH_HEADER) === "1") {
return null;
}
return response;
};
}
@@ -0,0 +1,42 @@
import type { BaseIssue, BaseSchema, InferOutput } from "valibot";
import { safeParse } from "valibot";
type ValidationIssue = {
path?: string;
message: string;
};
function formatIssue(issue: BaseIssue<unknown>): ValidationIssue {
const path = issue.path
?.map((item) => String(item.key))
.filter(Boolean)
.join(".");
return path ? { path, message: issue.message } : { message: issue.message };
}
export async function parseJsonBody<
const TSchema extends BaseSchema<unknown, unknown, BaseIssue<unknown>>,
>(req: Request, schema: TSchema): Promise<InferOutput<TSchema>> {
let input: unknown;
try {
input = await req.json();
} catch {
throw Response.json(
{ error: "Invalid JSON body" },
{ status: 400 },
);
}
const result = safeParse(schema, input);
if (!result.success) {
throw Response.json(
{
error: "Invalid request body",
issues: result.issues.map(formatIssue),
},
{ status: 400 },
);
}
return result.output;
}
@@ -9,6 +9,7 @@ import {
setUserGroups, setUserGroups,
} from "../../users"; } from "../../users";
import { createHttpFetchHandler } from "../http-dispatch"; import { createHttpFetchHandler } from "../http-dispatch";
import { createHonoRouteHandler } from "../hono-router";
import { handlePermissionGroupsRoutes } from "./permission-groups-routes"; import { handlePermissionGroupsRoutes } from "./permission-groups-routes";
import { handleUsersRoutes } from "./users-routes"; import { handleUsersRoutes } from "./users-routes";
@@ -55,7 +56,10 @@ function callThroughServerRoutes(method: string, path: string, token: string | n
metrics: { withHttpMetrics: (fn) => fn() }, metrics: { withHttpMetrics: (fn) => fn() },
CORS_HEADERS: {}, CORS_HEADERS: {},
routes: [ routes: [
(req, routeUrl, server) => handleUsersRoutes(req, routeUrl, server as typeof mockServer), createHonoRouteHandler([{
basePath: "/api/users",
handler: (req, routeUrl, server) => handleUsersRoutes(req, routeUrl, server as typeof mockServer),
}]),
(req, routeUrl, server) => handlePermissionGroupsRoutes(req, routeUrl, server as typeof mockServer), (req, routeUrl, server) => handlePermissionGroupsRoutes(req, routeUrl, server as typeof mockServer),
], ],
}); });
@@ -0,0 +1,59 @@
import {
array,
boolean,
minLength,
object,
optional,
partialCheck,
picklist,
pipe,
record,
string,
} from "valibot";
const nonEmptyString = pipe(string(), minLength(1, "Must not be empty"));
export const createUserBodySchema = object({
username: nonEmptyString,
password: nonEmptyString,
role: picklist(["admin", "operator", "viewer"], "Invalid role"),
mustChangePassword: optional(boolean()),
});
export const updatePasswordBodySchema = pipe(
object({
password: optional(nonEmptyString),
newPassword: optional(nonEmptyString),
currentPassword: optional(string()),
}),
partialCheck(
[["password"], ["newPassword"]],
(input) => Boolean(input.password || input.newPassword),
"Password required",
),
);
export const updateRoleBodySchema = object({
role: picklist(["admin", "operator", "viewer"], "Invalid role"),
});
export const updateClientAccessBodySchema = object({
scope: picklist(["all", "allowlist", "denylist", "none"], "Invalid client access scope"),
});
export const clientAccessRuleBodySchema = object({
clientId: nonEmptyString,
access: picklist(["allow", "deny"], "Invalid client access rule"),
});
export const updateCanBuildBodySchema = object({ canBuild: boolean() });
export const updateCanUploadFilesBodySchema = object({ canUploadFiles: boolean() });
export const updateFeaturePermissionsBodySchema = object({
permissions: record(string(), boolean()),
});
export const updatePluginAccessBodySchema = object({
scope: picklist(["all", "allowlist", "none"], "Invalid plugin access scope"),
pluginIds: optional(array(nonEmptyString)),
});
@@ -30,6 +30,48 @@ afterEach(() => {
}); });
describe("admin-created users must change password", () => { describe("admin-created users must change password", () => {
test("POST /api/users rejects malformed JSON as a client error", async () => {
const admin = await createAuthedAdmin();
const url = new URL("https://localhost/api/users");
const res = await handleUsersRoutes(
new Request(url, {
method: "POST",
headers: {
Authorization: `Bearer ${admin.token}`,
"Content-Type": "application/json",
},
body: "{not-json",
}),
url,
mockServer,
);
expect(res?.status).toBe(400);
expect(await res?.json()).toEqual({ error: "Invalid JSON body" });
});
test("POST /api/users reports schema validation issues", async () => {
const admin = await createAuthedAdmin();
const url = new URL("https://localhost/api/users");
const res = await handleUsersRoutes(
new Request(url, {
method: "POST",
headers: {
Authorization: `Bearer ${admin.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ username: "new-user", password: PASSWORD, role: "owner" }),
}),
url,
mockServer,
);
expect(res?.status).toBe(400);
const body = await res?.json();
expect(body.error).toBe("Invalid request body");
expect(body.issues).toContainEqual({ path: "role", message: "Invalid role" });
});
test("POST /api/users marks new users for password change", async () => { test("POST /api/users marks new users for password change", async () => {
const admin = await createAuthedAdmin(); const admin = await createAuthedAdmin();
const username = `mcp_user_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`; const username = `mcp_user_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
@@ -35,6 +35,18 @@ import {
updateUserRole, updateUserRole,
} from "../../users"; } from "../../users";
import { makeAuthCookie } from "./auth-cookie"; import { makeAuthCookie } from "./auth-cookie";
import { parseJsonBody } from "../request-validation";
import {
clientAccessRuleBodySchema,
createUserBodySchema,
updateCanBuildBodySchema,
updateCanUploadFilesBodySchema,
updateClientAccessBodySchema,
updateFeaturePermissionsBodySchema,
updatePasswordBodySchema,
updatePluginAccessBodySchema,
updateRoleBodySchema,
} from "./user-request-schemas";
type RequestIpProvider = { type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined; requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -83,17 +95,9 @@ export async function handleUsersRoutes(
if (req.method === "POST" && url.pathname === "/api/users") { if (req.method === "POST" && url.pathname === "/api/users") {
const authedUser = requirePermission(user, "users:manage"); const authedUser = requirePermission(user, "users:manage");
const body = await req.json(); const body = await parseJsonBody(req, createUserBodySchema);
const { username, password, role, mustChangePassword } = body; const { username, password, role, mustChangePassword } = body;
if (!username || !password || !role) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
if (!["admin", "operator", "viewer"].includes(role)) {
return Response.json({ error: "Invalid role" }, { status: 400 });
}
const result = await createUser( const result = await createUser(
username, username,
password, password,
@@ -123,7 +127,7 @@ export async function handleUsersRoutes(
return Response.json({ error: "Not authenticated" }, { status: 401 }); return Response.json({ error: "Not authenticated" }, { status: 401 });
} }
const userId = parseInt(url.pathname.split("/")[3]); const userId = parseInt(url.pathname.split("/")[3]);
const body = await req.json(); const body = await parseJsonBody(req, updatePasswordBodySchema);
const { password, newPassword, currentPassword } = body; const { password, newPassword, currentPassword } = body;
const canChange = user.userId === userId || user.role === "admin"; const canChange = user.userId === userId || user.role === "admin";
@@ -209,13 +213,9 @@ export async function handleUsersRoutes(
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/role$/)) { if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/role$/)) {
const authedUser = requirePermission(user, "users:manage"); const authedUser = requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]); const userId = parseInt(url.pathname.split("/")[3]);
const body = await req.json(); const body = await parseJsonBody(req, updateRoleBodySchema);
const { role } = body; const { role } = body;
if (!role || !["admin", "operator", "viewer"].includes(role)) {
return Response.json({ error: "Invalid role" }, { status: 400 });
}
if (userId === authedUser.userId) { if (userId === authedUser.userId) {
return Response.json({ error: "Cannot change your own role" }, { status: 400 }); return Response.json({ error: "Cannot change your own role" }, { status: 400 });
} }
@@ -286,8 +286,8 @@ export async function handleUsersRoutes(
return Response.json({ error: "User not found" }, { status: 404 }); return Response.json({ error: "User not found" }, { status: 404 });
} }
const body = await req.json(); const body = await parseJsonBody(req, updateClientAccessBodySchema);
const scope = body?.scope as ClientAccessScope; const scope = body.scope as ClientAccessScope;
const result = setUserClientAccessScope(userId, scope); const result = setUserClientAccessScope(userId, scope);
if (!result.success) { if (!result.success) {
return Response.json({ error: result.error }, { status: 400 }); return Response.json({ error: result.error }, { status: 400 });
@@ -314,9 +314,9 @@ export async function handleUsersRoutes(
return Response.json({ error: "User not found" }, { status: 404 }); return Response.json({ error: "User not found" }, { status: 404 });
} }
const body = await req.json(); const body = await parseJsonBody(req, clientAccessRuleBodySchema);
const clientId = String(body?.clientId || "").trim(); const clientId = body.clientId.trim();
const access = body?.access as ClientAccessRuleKind; const access = body.access as ClientAccessRuleKind;
const result = setUserClientAccessRule(userId, clientId, access); const result = setUserClientAccessRule(userId, clientId, access);
if (!result.success) { if (!result.success) {
return Response.json({ error: result.error }, { status: 400 }); return Response.json({ error: result.error }, { status: 400 });
@@ -411,8 +411,8 @@ export async function handleUsersRoutes(
return Response.json({ error: "User not found" }, { status: 404 }); return Response.json({ error: "User not found" }, { status: 404 });
} }
const body = await req.json(); const body = await parseJsonBody(req, updateCanBuildBodySchema);
const canBuild = !!body?.canBuild; const canBuild = body.canBuild;
const previous = Boolean(targetUser.can_build); const previous = Boolean(targetUser.can_build);
const result = setUserCanBuild(userId, canBuild); const result = setUserCanBuild(userId, canBuild);
if (!result.success) { if (!result.success) {
@@ -445,8 +445,8 @@ export async function handleUsersRoutes(
return Response.json({ error: "User not found" }, { status: 404 }); return Response.json({ error: "User not found" }, { status: 404 });
} }
const body = await req.json(); const body = await parseJsonBody(req, updateCanUploadFilesBodySchema);
const canUploadFiles = !!body?.canUploadFiles; const canUploadFiles = body.canUploadFiles;
const previous = Boolean(targetUser.can_upload_files); const previous = Boolean(targetUser.can_upload_files);
const result = setUserCanUploadFiles(userId, canUploadFiles); const result = setUserCanUploadFiles(userId, canUploadFiles);
if (!result.success) { if (!result.success) {
@@ -491,11 +491,8 @@ export async function handleUsersRoutes(
return Response.json({ error: "User not found" }, { status: 404 }); return Response.json({ error: "User not found" }, { status: 404 });
} }
const body = await req.json(); const body = await parseJsonBody(req, updateFeaturePermissionsBodySchema);
const permissions = body?.permissions; const permissions = body.permissions;
if (!permissions || typeof permissions !== "object") {
return Response.json({ error: "Invalid permissions object" }, { status: 400 });
}
const validated: Partial<Record<FeatureName, boolean>> = {}; const validated: Partial<Record<FeatureName, boolean>> = {};
for (const [key, value] of Object.entries(permissions)) { for (const [key, value] of Object.entries(permissions)) {
@@ -603,9 +600,9 @@ export async function handleUsersRoutes(
return Response.json({ error: "User not found" }, { status: 404 }); return Response.json({ error: "User not found" }, { status: 404 });
} }
const body = await req.json(); const body = await parseJsonBody(req, updatePluginAccessBodySchema);
const scope = body?.scope as PluginAccessScope; const scope = body.scope as PluginAccessScope;
const pluginIds = Array.isArray(body?.pluginIds) ? body.pluginIds.filter((id: any) => typeof id === "string" && id.trim()) : undefined; const pluginIds = body.pluginIds?.map((id) => id.trim());
const scopeResult = setUserPluginAccessScope(userId, scope); const scopeResult = setUserPluginAccessScope(userId, scope);
if (!scopeResult.success) { if (!scopeResult.success) {
+21
View File
@@ -0,0 +1,21 @@
# Browser TypeScript
Browser source lives here and is bundled into `public/assets/generated/` by
`bun run build:web`. Generated assets are ignored by Git and must not be edited
directly.
The development supervisor runs the browser build once and watches this folder.
Production and E2E preparation also run the build before serving assets.
Migrate existing `public/assets/*.js` modules incrementally:
1. Move one leaf/shared module into this directory and add it as a browser
entrypoint in `package.json`.
2. Keep its public export names stable and update consumers to import from
`/assets/generated/` (or the equivalent relative path).
3. Add runtime schemas for data crossing HTTP, WebSocket, storage, or plugin
boundaries; TypeScript types alone do not validate external data.
4. Run `bun run typecheck`, `bun run build:web`, and the affected browser tests.
Do not duplicate or replace the generated wire-protocol types here. Wire changes
must continue through `protocol/wire-contract.json` and its generator.
@@ -1,30 +1,44 @@
export async function loadSharedUiSettings(scope) { import { object, record, safeParse, string, unknown } from "valibot";
export type SharedUiSettings = Record<string, unknown>;
const sharedUiSettingsResponseSchema = object({
settings: record(string(), unknown()),
});
export async function loadSharedUiSettings(scope: string): Promise<SharedUiSettings> {
try { try {
const res = await fetch(`/api/ui-settings/${encodeURIComponent(scope)}`, { const res = await fetch(`/api/ui-settings/${encodeURIComponent(scope)}`, {
credentials: "include", credentials: "include",
}); });
if (!res.ok) return {}; if (!res.ok) return {};
const data = await res.json().catch(() => ({}));
return data && typeof data.settings === "object" && data.settings ? data.settings : {}; const input: unknown = await res.json().catch(() => null);
const result = safeParse(sharedUiSettingsResponseSchema, input);
return result.success ? result.output.settings : {};
} catch (err) { } catch (err) {
console.warn(`ui-settings: failed to load ${scope}`, err); console.warn(`ui-settings: failed to load ${scope}`, err);
return {}; return {};
} }
} }
export function createSharedUiSettingsSaver(scope, readSettings, delayMs = 350) { export function createSharedUiSettingsSaver(
let timer = null; scope: string,
readSettings: () => SharedUiSettings | null | undefined,
delayMs = 350,
) {
let timer: ReturnType<typeof setTimeout> | null = null;
let lastPayload = ""; let lastPayload = "";
async function saveNow() { async function saveNow(): Promise<void> {
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer);
timer = null; timer = null;
} }
let settings = {}; let settings: SharedUiSettings;
try { try {
settings = readSettings() || {}; settings = readSettings() ?? {};
} catch (err) { } catch (err) {
console.warn(`ui-settings: failed to read ${scope}`, err); console.warn(`ui-settings: failed to read ${scope}`, err);
return; return;
@@ -42,16 +56,14 @@ export function createSharedUiSettingsSaver(scope, readSettings, delayMs = 350)
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings }), body: JSON.stringify({ settings }),
}); });
if (!res.ok) { if (!res.ok) lastPayload = "";
lastPayload = "";
}
} catch (err) { } catch (err) {
lastPayload = ""; lastPayload = "";
console.warn(`ui-settings: failed to save ${scope}`, err); console.warn(`ui-settings: failed to save ${scope}`, err);
} }
} }
function scheduleSave() { function scheduleSave(): void {
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
timer = setTimeout(saveNow, delayMs); timer = setTimeout(saveNow, delayMs);
} }
@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
import { login } from "./helpers";
const TRANSPARENT_GIF = Buffer.from(
"R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==",
"base64",
);
test("easter egg image is requested only after browser-local opt-in", async ({ page }) => {
let imageRequests = 0;
await page.route("**/assets/console.gif", async (route) => {
imageRequests += 1;
await route.fulfill({
status: 200,
contentType: "image/gif",
body: TRANSPARENT_GIF,
});
});
await login(page);
expect(imageRequests).toBe(0);
await page.goto("/settings");
const toggle = page.locator("#easter-egg-enabled");
const status = page.locator("#easter-egg-status");
await expect(toggle).not.toBeChecked();
await toggle.check();
await expect(status).toContainText("Enabled and shown");
expect(imageRequests).toBe(1);
await page.reload();
await expect(toggle).toBeChecked();
expect(imageRequests).toBe(1);
await toggle.uncheck();
await expect(status).toContainText("will not be requested");
await page.reload();
await expect(toggle).not.toBeChecked();
expect(imageRequests).toBe(1);
});