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
Overlord-Server/public/assets/tailwind.css
Overlord-Server/public/vendor/
Overlord-Server/public/assets/generated/
Overlord-Desktop/src/vendor/
# Tauri
+6
View File
@@ -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=="],
+7 -3
View File
@@ -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"
},
+1 -1
View File
@@ -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;
}
+3 -37
View File
@@ -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");
+40
View File
@@ -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")) {
+1 -1
View File
@@ -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");
+22
View File
@@ -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>
+12 -1
View File
@@ -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]);
+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 { 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();
});
});
+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,
} 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) {
+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 {
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);
});