security update + make easter egg optional.
This commit is contained in:
@@ -15,6 +15,7 @@ dist/
|
||||
# Build output
|
||||
Overlord-Server/public/assets/tailwind.css
|
||||
Overlord-Server/public/vendor/
|
||||
Overlord-Server/public/assets/generated/
|
||||
Overlord-Desktop/src/vendor/
|
||||
|
||||
# Tauri
|
||||
|
||||
@@ -15,10 +15,12 @@
|
||||
"fuse.js": "^7.4.1",
|
||||
"geoip-lite": "latest",
|
||||
"gridstack": "^12.6.0",
|
||||
"hono": "^4.12.33",
|
||||
"jose": "^6.1.3",
|
||||
"msgpackr": "1.11.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^9.0.1",
|
||||
"valibot": "^1.4.2",
|
||||
"web-push": "^3.6.7",
|
||||
"winston": "^3.11.0",
|
||||
},
|
||||
@@ -342,6 +344,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
@@ -6,13 +6,15 @@
|
||||
"scripts": {
|
||||
"test": "bun run scripts/run-tests-isolated.ts",
|
||||
"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",
|
||||
"dev": "bun run scripts/dev.ts",
|
||||
"start": "bun run build && bun dist/index.js",
|
||||
"build": "bun run build:css && bun run vendor && bun run build:bundle",
|
||||
"build:public:prod": "bun run build:css && bun run vendor && bun run minify",
|
||||
"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 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: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",
|
||||
"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",
|
||||
@@ -35,10 +37,12 @@
|
||||
"fuse.js": "^7.4.1",
|
||||
"geoip-lite": "latest",
|
||||
"gridstack": "^12.6.0",
|
||||
"hono": "^4.12.33",
|
||||
"jose": "^6.1.3",
|
||||
"msgpackr": "1.11.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^9.0.1",
|
||||
"valibot": "^1.4.2",
|
||||
"web-push": "^3.6.7",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
||||
import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
import { WhepClient } from "./whep.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 () {
|
||||
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;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./data.js";
|
||||
import { ThumbnailLoader } from "./thumbnail-loader.js";
|
||||
import { initDashboardStats, updateDashboardStatsFromClients } from "./dashboard-stats.js";
|
||||
import { showEnabledEasterEgg } from "./easter-egg.js";
|
||||
|
||||
const grid = document.getElementById("grid");
|
||||
const totalPill = document.getElementById("total-pill");
|
||||
@@ -2243,40 +2244,5 @@ menu.addEventListener("click", async (e) => {
|
||||
|
||||
loadCurrentUser();
|
||||
|
||||
// console easter egg
|
||||
(function () {
|
||||
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" }));
|
||||
})();
|
||||
// Opt-in console easter egg. Disabled users never request the large image.
|
||||
showEnabledEasterEgg().catch((err) => console.warn(err.message));
|
||||
|
||||
@@ -4,7 +4,7 @@ import { WhepClient } from "./whep.js";
|
||||
import { P2PClient } from "./webrtc-p2p.js";
|
||||
import { AdaptiveDesktopQuality, normalizeAdaptiveProfiles } from "./rd-adaptive-quality.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 () {
|
||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
} from "./notify-client.js";
|
||||
import { escapeHtml, formatBytes as formatSharedBytes, formatDate as formatSharedDate } from "./format.js";
|
||||
import { getCertificateTrustGuide } from "./cert-banner.js";
|
||||
import {
|
||||
isEasterEggEnabled,
|
||||
setEasterEggEnabled,
|
||||
showEnabledEasterEgg,
|
||||
} from "./easter-egg.js";
|
||||
|
||||
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
||||
const NAV_MODE_KEY = "sb_mode";
|
||||
@@ -40,6 +45,8 @@ const prefNotificationsInput = document.getElementById("pref-notifications");
|
||||
const prefDesktopNotificationsInput = document.getElementById("pref-desktop-notifications");
|
||||
const prefDesktopNotificationsHint = document.getElementById("pref-desktop-notifications-hint");
|
||||
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 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() {
|
||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
@@ -2926,6 +2965,7 @@ async function init() {
|
||||
await loadCurrentUser();
|
||||
applyPermissionVisibility();
|
||||
loadPrefs();
|
||||
initEasterEggPreference();
|
||||
await loadMfaStatus();
|
||||
|
||||
if (userHas("clients:build")) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
import { checkFeatureAccess } from "./feature-gate.js";
|
||||
import { WhepClient } from "./whep.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 () {
|
||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||
|
||||
@@ -1781,6 +1781,28 @@
|
||||
</div>
|
||||
</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>
|
||||
<!-- /Sections -->
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ function runPreparation(command: string): void {
|
||||
}
|
||||
|
||||
runPreparation("build:css");
|
||||
runPreparation("build:web");
|
||||
runPreparation("vendor");
|
||||
|
||||
const backend = Bun.spawn(["bun", "--watch", "src/index.ts"], {
|
||||
@@ -44,6 +45,14 @@ const styles = Bun.spawn(["bun", "run", "watch:css"], {
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
const web = Bun.spawn(["bun", "run", "watch:web"], {
|
||||
cwd,
|
||||
env,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
|
||||
function stop(exitCode = 0): void {
|
||||
@@ -51,6 +60,7 @@ function stop(exitCode = 0): void {
|
||||
stopping = true;
|
||||
backend.kill();
|
||||
styles.kill();
|
||||
web.kill();
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
@@ -60,6 +70,7 @@ process.on("SIGTERM", () => stop(143));
|
||||
const result = await Promise.race([
|
||||
backend.exited.then((exitCode) => ({ process: "backend", exitCode })),
|
||||
styles.exited.then((exitCode) => ({ process: "Tailwind watcher", exitCode })),
|
||||
web.exited.then((exitCode) => ({ process: "browser TypeScript watcher", exitCode })),
|
||||
]);
|
||||
|
||||
if (!stopping) {
|
||||
@@ -67,4 +78,4 @@ if (!stopping) {
|
||||
stop(result.exitCode || 1);
|
||||
}
|
||||
|
||||
await Promise.allSettled([backend.exited, styles.exited]);
|
||||
await Promise.allSettled([backend.exited, styles.exited, web.exited]);
|
||||
|
||||
@@ -58,6 +58,7 @@ import { handleRegistrationRoutes } from "./server/routes/registration-routes";
|
||||
import { consumeHttpDownloadPayload, type PendingHttpDownload } from "./server/http-download-consumer";
|
||||
import { startBuildProcess as runBuildProcess } from "./server/build-process";
|
||||
import { createHttpFetchHandler } from "./server/http-dispatch";
|
||||
import { createHonoRouteHandler } from "./server/hono-router";
|
||||
import { startMaintenanceLoops } from "./server/maintenance-loops";
|
||||
import {
|
||||
deliverNotificationWithScreenshot,
|
||||
@@ -711,6 +712,13 @@ async function startServer() {
|
||||
handleCrashReport: notificationPluginHandlers.handleCrashReport,
|
||||
};
|
||||
|
||||
const routedHttpHandler = createHonoRouteHandler([
|
||||
{
|
||||
basePath: "/api/users",
|
||||
handler: (req, url, srv) => handleUsersRoutes(req, url, srv as any),
|
||||
},
|
||||
]);
|
||||
|
||||
const server = Bun.serve<SocketData>({
|
||||
port: PORT,
|
||||
hostname: HOST,
|
||||
@@ -742,7 +750,7 @@ async function startServer() {
|
||||
(req, url) => handleEnrollmentRoutes(req, url),
|
||||
(req, url) => handleChatRoutes(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) => handleBuildRoutes(req, url, srv as any, routeDeps.build),
|
||||
(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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
} from "../../users";
|
||||
import { createHttpFetchHandler } from "../http-dispatch";
|
||||
import { createHonoRouteHandler } from "../hono-router";
|
||||
import { handlePermissionGroupsRoutes } from "./permission-groups-routes";
|
||||
import { handleUsersRoutes } from "./users-routes";
|
||||
|
||||
@@ -55,7 +56,10 @@ function callThroughServerRoutes(method: string, path: string, token: string | n
|
||||
metrics: { withHttpMetrics: (fn) => fn() },
|
||||
CORS_HEADERS: {},
|
||||
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),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
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 () => {
|
||||
const admin = await createAuthedAdmin();
|
||||
const username = `mcp_user_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
|
||||
@@ -35,6 +35,18 @@ import {
|
||||
updateUserRole,
|
||||
} from "../../users";
|
||||
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 = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
@@ -83,17 +95,9 @@ export async function handleUsersRoutes(
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/users") {
|
||||
const authedUser = requirePermission(user, "users:manage");
|
||||
const body = await req.json();
|
||||
const body = await parseJsonBody(req, createUserBodySchema);
|
||||
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(
|
||||
username,
|
||||
password,
|
||||
@@ -123,7 +127,7 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const userId = parseInt(url.pathname.split("/")[3]);
|
||||
const body = await req.json();
|
||||
const body = await parseJsonBody(req, updatePasswordBodySchema);
|
||||
const { password, newPassword, currentPassword } = body;
|
||||
|
||||
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$/)) {
|
||||
const authedUser = requirePermission(user, "users:manage");
|
||||
const userId = parseInt(url.pathname.split("/")[3]);
|
||||
const body = await req.json();
|
||||
const body = await parseJsonBody(req, updateRoleBodySchema);
|
||||
const { role } = body;
|
||||
|
||||
if (!role || !["admin", "operator", "viewer"].includes(role)) {
|
||||
return Response.json({ error: "Invalid role" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (userId === authedUser.userId) {
|
||||
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 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const scope = body?.scope as ClientAccessScope;
|
||||
const body = await parseJsonBody(req, updateClientAccessBodySchema);
|
||||
const scope = body.scope as ClientAccessScope;
|
||||
const result = setUserClientAccessScope(userId, scope);
|
||||
if (!result.success) {
|
||||
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 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const clientId = String(body?.clientId || "").trim();
|
||||
const access = body?.access as ClientAccessRuleKind;
|
||||
const body = await parseJsonBody(req, clientAccessRuleBodySchema);
|
||||
const clientId = body.clientId.trim();
|
||||
const access = body.access as ClientAccessRuleKind;
|
||||
const result = setUserClientAccessRule(userId, clientId, access);
|
||||
if (!result.success) {
|
||||
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 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const canBuild = !!body?.canBuild;
|
||||
const body = await parseJsonBody(req, updateCanBuildBodySchema);
|
||||
const canBuild = body.canBuild;
|
||||
const previous = Boolean(targetUser.can_build);
|
||||
const result = setUserCanBuild(userId, canBuild);
|
||||
if (!result.success) {
|
||||
@@ -445,8 +445,8 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const canUploadFiles = !!body?.canUploadFiles;
|
||||
const body = await parseJsonBody(req, updateCanUploadFilesBodySchema);
|
||||
const canUploadFiles = body.canUploadFiles;
|
||||
const previous = Boolean(targetUser.can_upload_files);
|
||||
const result = setUserCanUploadFiles(userId, canUploadFiles);
|
||||
if (!result.success) {
|
||||
@@ -491,11 +491,8 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const permissions = body?.permissions;
|
||||
if (!permissions || typeof permissions !== "object") {
|
||||
return Response.json({ error: "Invalid permissions object" }, { status: 400 });
|
||||
}
|
||||
const body = await parseJsonBody(req, updateFeaturePermissionsBodySchema);
|
||||
const permissions = body.permissions;
|
||||
|
||||
const validated: Partial<Record<FeatureName, boolean>> = {};
|
||||
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 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const scope = body?.scope as PluginAccessScope;
|
||||
const pluginIds = Array.isArray(body?.pluginIds) ? body.pluginIds.filter((id: any) => typeof id === "string" && id.trim()) : undefined;
|
||||
const body = await parseJsonBody(req, updatePluginAccessBodySchema);
|
||||
const scope = body.scope as PluginAccessScope;
|
||||
const pluginIds = body.pluginIds?.map((id) => id.trim());
|
||||
|
||||
const scopeResult = setUserPluginAccessScope(userId, scope);
|
||||
if (!scopeResult.success) {
|
||||
|
||||
@@ -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.
|
||||
+24
-12
@@ -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 {
|
||||
const res = await fetch(`/api/ui-settings/${encodeURIComponent(scope)}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
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) {
|
||||
console.warn(`ui-settings: failed to load ${scope}`, err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function createSharedUiSettingsSaver(scope, readSettings, delayMs = 350) {
|
||||
let timer = null;
|
||||
export function createSharedUiSettingsSaver(
|
||||
scope: string,
|
||||
readSettings: () => SharedUiSettings | null | undefined,
|
||||
delayMs = 350,
|
||||
) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastPayload = "";
|
||||
|
||||
async function saveNow() {
|
||||
async function saveNow(): Promise<void> {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
let settings = {};
|
||||
let settings: SharedUiSettings;
|
||||
try {
|
||||
settings = readSettings() || {};
|
||||
settings = readSettings() ?? {};
|
||||
} catch (err) {
|
||||
console.warn(`ui-settings: failed to read ${scope}`, err);
|
||||
return;
|
||||
@@ -42,16 +56,14 @@ export function createSharedUiSettingsSaver(scope, readSettings, delayMs = 350)
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ settings }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
lastPayload = "";
|
||||
}
|
||||
if (!res.ok) lastPayload = "";
|
||||
} catch (err) {
|
||||
lastPayload = "";
|
||||
console.warn(`ui-settings: failed to save ${scope}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
function scheduleSave(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user